Compare commits

..

3 Commits

Author SHA1 Message Date
bf43e55848 feat(onboarding): decision-first verify-step & contextual-help callout fix (#282)
Some checks failed
Main Confidence / confidence (push) Failing after 53s
Heavy Governance Lane / heavy-governance (push) Has been skipped
Browser Lane / browser (push) Has been skipped
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 <ahmed.darrazi@live.de>
Reviewed-on: #282
2026-04-27 00:09:46 +00:00
6053d87b99 feat: implement product usage adoption telemetry (#281)
Some checks failed
Main Confidence / confidence (push) Failing after 48s
## 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 <ahmed.darrazi@live.de>
Reviewed-on: #281
2026-04-26 20:52:38 +00:00
d96abc65fb Remove Findings lifecycle backfill operational surface (controls slice) (#280)
Some checks failed
Main Confidence / confidence (push) Failing after 1m23s
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 <ahmed.darrazi@live.de>
Reviewed-on: #280
2026-04-26 15:43:47 +00:00
62 changed files with 6209 additions and 1000 deletions

View File

@ -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/<number>-<slug>/`
- 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/<number>-<slug>/
```
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/<number>-<slug>/spec.md
specs/<number>-<slug>/plan.md
specs/<number>-<slug>/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 <specific behavior>.
- [ ] Update <specific Filament page/resource> to display <specific state>.
- [ ] Add policy coverage for <specific capability>.
```
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 `<spec-branch-or-spec-path>` 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 `<spec-branch-or-spec-path>` 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 `<spec-number>-<slug>` 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.
```

View File

@ -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/<number>-<slug>/`
- 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 `<spec-number>-<slug>` 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.
```

View File

@ -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/<number>-<slug>/
```
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/<number>-<slug>/spec.md
specs/<number>-<slug>/plan.md
specs/<number>-<slug>/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 <specific behavior>.
- [ ] Update <specific Filament page/resource> to display <specific state>.
- [ ] Add policy coverage for <specific capability>.
```
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 `<spec-branch-or-spec-path>` 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 `<spec-branch-or-spec-path>` 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.
```

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\ProductUsageEvent;
use Illuminate\Console\Command;
class PruneProductUsageEventsCommand extends Command
{
/**
* @var string
*/
protected $signature = 'tenantpilot:product-usage:prune {--days= : Number of days to retain product usage events}';
/**
* @var string
*/
protected $description = 'Delete product usage events older than the retention period';
public function handle(): int
{
$days = (int) ($this->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;
}
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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<string, mixed>|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<string, int>, freshness: array{last_refreshed_at: string|null, is_stale: bool}, missing_permissions: array{application: list<string>, delegated: list<string>}, 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<string, mixed> $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<string, mixed> $verificationReport
* @return array<string, mixed>|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();

View File

@ -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,
]),
];
}

View File

@ -19,12 +19,14 @@ class ControlTowerKpis extends StatsOverviewWidget
protected int|string|array $columnSpan = 'full';
public ?string $window = null;
/**
* @return array<Stat>
*/
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);

View File

@ -21,12 +21,14 @@ class ControlTowerRecentFailures extends Widget
protected string $view = 'filament.system.widgets.control-tower-recent-failures';
public ?string $window = null;
/**
* @return array<string, mixed>
*/
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<int, OperationRun> $runs */

View File

@ -23,12 +23,14 @@ class ControlTowerTopOffenders extends Widget
protected string $view = 'filament.system.widgets.control-tower-top-offenders';
public ?string $window = null;
/**
* @return array<string, mixed>
*/
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<int, OperationRun> $grouped */

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Filament\System\Widgets;
use App\Support\ProductTelemetry\ProductTelemetrySummaryQuery;
use App\Support\SystemConsole\SystemConsoleWindow;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class ProductTelemetryKpis extends StatsOverviewWidget
{
protected static bool $isLazy = false;
protected ?string $heading = 'Product telemetry';
protected int|string|array $columnSpan = 'full';
public ?string $window = null;
/**
* @return array<Stat>
*/
protected function getStats(): array
{
$window = SystemConsoleWindow::fromNullable($this->window ?? (string) request()->query('window'));
$windowLabel = SystemConsoleWindow::options()[$window->value] ?? 'Last 24 hours';
$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;
}
}

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProductUsageEvent extends Model
{
use DerivesWorkspaceIdFromTenant;
/** @use HasFactory<\Database\Factories\ProductUsageEventFactory> */
use HasFactory;
protected $guarded = [];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'metadata' => 'array',
'occurred_at' => 'datetime',
];
}
/**
* @return BelongsTo<Workspace, $this>
*/
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class)->withTrashed();
}
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -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(),
);
}
}

View File

@ -179,5 +179,7 @@ private function persistDraft(TenantOnboardingSession $draft, bool $incrementVer
$this->lifecycleService->applySnapshot($draft, false);
$draft->save();
$this->lifecycleService->recordCompletedCheckpointTelemetryIfNeeded($draft);
}
}

View File

@ -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,

View File

@ -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.
*

View File

@ -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<string, mixed>
*/

View File

@ -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.
*/

View File

@ -0,0 +1,263 @@
<?php
declare(strict_types=1);
namespace App\Support\ProductKnowledge;
use App\Support\Links\RequiredPermissionsLinks;
use InvalidArgumentException;
final class ContextualHelpCatalog
{
public const string ADMIN_CONSENT_REQUIRED = 'admin-consent-required';
public const string REQUIRED_PERMISSIONS_MISSING = 'required-permissions-missing';
public const string CONNECTION_UNHEALTHY = 'connection-unhealthy';
public const string VERIFICATION_STALE = 'verification-stale';
public const string VERIFICATION_FAILED = 'verification-failed';
public const string DIAGNOSTIC_EVIDENCE_INCOMPLETE = 'diagnostic-evidence-incomplete';
public const string RETRYABLE_PROVIDER_FAILURE = 'retryable-provider-failure';
public const string MANUAL_HANDOFF_REQUIRED = 'manual-handoff-required';
public const string LINK_RESOLVER_ADMIN_CONSENT_PRIMARY = 'admin_consent_primary';
public const string LINK_RESOLVER_REQUIRED_PERMISSIONS = 'required_permissions';
/**
* @return list<string>
*/
public function keys(): array
{
return array_keys($this->definitions());
}
/**
* @return array<string, array{
* topic_key: string,
* surface_families: list<string>,
* headline: string,
* short_explanation: string,
* troubleshooting_steps: list<string>,
* safe_next_action: string,
* glossary_terms: list<string>,
* docs_links: list<array{
* label: string,
* kind: string,
* url?: string,
* resolver?: string
* }>
* }>
*/
public function 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<string>,
* headline: string,
* short_explanation: string,
* troubleshooting_steps: list<string>,
* safe_next_action: string,
* glossary_terms: list<string>,
* docs_links: list<array{
* label: string,
* kind: string,
* url?: string,
* resolver?: string
* }>
* }
*/
public function 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<array{
* topic_key: string,
* surface_families: list<string>,
* headline: string,
* short_explanation: string,
* troubleshooting_steps: list<string>,
* safe_next_action: string,
* glossary_terms: list<string>,
* docs_links: list<array{
* label: string,
* kind: string,
* url: ?string,
* resolver: ?string
* }>
* }>
* }
*/
public function 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,
];
}
}

View File

@ -0,0 +1,427 @@
<?php
declare(strict_types=1);
namespace App\Support\ProductKnowledge;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\Links\RequiredPermissionsLinks;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder;
use InvalidArgumentException;
final class ContextualHelpResolver
{
public function __construct(
private readonly ContextualHelpCatalog $catalog,
private readonly PlatformVocabularyGlossary $glossary,
private readonly ReasonPresenter $reasonPresenter,
private readonly OperatorExplanationBuilder $operatorExplanationBuilder,
) {}
/**
* @param array<string, mixed> $context
* @return array{
* topic_key: string,
* surface_families: list<string>,
* headline: string,
* short_explanation: string,
* troubleshooting_steps: list<string>,
* safe_next_action: string,
* docs_links: list<array{
* label: string,
* kind: string,
* url: ?string,
* resolver: ?string
* }>,
* canonical_terms: list<string>,
* 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<string, mixed> $context
* @return array{
* topic_key: string,
* surface_families: list<string>,
* headline: string,
* short_explanation: string,
* troubleshooting_steps: list<string>,
* safe_next_action: string,
* docs_links: list<array{
* label: string,
* kind: string,
* url: ?string,
* resolver: ?string
* }>,
* canonical_terms: list<string>,
* 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<array{
* topic_key: string,
* surface_families: list<string>,
* headline: string,
* short_explanation: string,
* troubleshooting_steps: list<string>,
* safe_next_action: string,
* glossary_terms: list<string>,
* docs_links: list<array{
* label: string,
* kind: string,
* url: ?string,
* resolver: ?string
* }>
* }>
* }
*/
public function knowledgeSource(): array
{
return $this->catalog->knowledgeSource();
}
/**
* @param array<string, mixed>|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<string, mixed> $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<string, mixed> $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<string, mixed>|null $verificationReport
* @return list<array<string, mixed>>
*/
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<string> $defaultSteps
* @param array<string, mixed> $context
* @return list<string>
*/
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<array{label: string, kind: string, url?: string, resolver?: string}> $links
* @param array<string, mixed> $context
* @return list<array{label: string, kind: string, url: ?string, resolver: ?string}>
*/
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<string> $terms
* @return list<string>
*/
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 '';
}
}

View File

@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace App\Support\ProductTelemetry;
use App\Models\ProductUsageEvent;
use BackedEnum;
use DateTimeInterface;
use Illuminate\Support\Carbon;
use InvalidArgumentException;
final class ProductTelemetryRecorder
{
public function __construct(private readonly ProductUsageEventCatalog $catalog) {}
/**
* @param array<string, mixed> $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<string, mixed> $metadata
* @return array<string, bool|int|string>
*/
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;
}
}

View File

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Support\ProductTelemetry;
use App\Models\ProductUsageEvent;
use DateTimeInterface;
use Illuminate\Support\Carbon;
final class ProductTelemetrySummaryQuery
{
public function __construct(private readonly ProductUsageEventCatalog $catalog) {}
/**
* @return array{
* window: array{start_at: string, end_at: string},
* active_workspaces: int,
* total_events: int,
* families: array<string, array{label: string, count: int}>
* }
*/
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<string, int> $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,
];
}
}

View File

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Support\ProductTelemetry;
use InvalidArgumentException;
final class ProductUsageEventCatalog
{
public const string ONBOARDING_CHECKPOINT_COMPLETED = 'product.onboarding.checkpoint_completed';
public const string SUPPORT_DIAGNOSTICS_OPENED = 'product.support_diagnostics.opened';
public const string OPERATIONS_STARTED = 'product.operations.started';
public const string STORED_REPORT_CREATED = 'product.stored_report.created';
public const string REVIEW_PACK_REQUESTED = 'product.review_pack.requested';
private const DEFINITIONS = [
self::ONBOARDING_CHECKPOINT_COMPLETED => [
'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<string>
*/
public function names(): array
{
return array_keys(self::DEFINITIONS);
}
/**
* @return array{feature_area: string, label: string, metadata_keys: list<string>}
*/
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<string>
*/
public function allowedMetadataKeys(string $eventName): array
{
return $this->definition($eventName)['metadata_keys'];
}
/**
* @return array<string, string>
*/
public function visibleFamilies(): array
{
$families = [];
foreach ($this->names() as $eventName) {
$families[$eventName] = $this->label($eventName);
}
return $families;
}
}

View File

@ -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<array<string, mixed>> $sections
* @return array<string, mixed>|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()

View File

@ -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),

View File

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\ProductUsageEvent;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<ProductUsageEvent>
*/
class ProductUsageEventFactory extends Factory
{
protected $model = ProductUsageEvent::class;
/**
* @return array<string, mixed>
*/
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<string, mixed> $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,
]);
}
}

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('product_usage_events', function (Blueprint $table): void {
$table->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');
}
};

View File

@ -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 !== [])
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900/80" data-testid="contextual-help-block">
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge color="info" size="sm">
Contextual help
</x-filament::badge>
@if ($showReasonLabel)
<x-filament::badge color="gray" size="sm">
{{ $reasonLabel }}
</x-filament::badge>
@endif
</div>
<div class="space-y-1">
<div class="text-sm font-semibold text-gray-950 dark:text-white">
{{ $headline }}
</div>
<div class="text-sm text-gray-700 dark:text-gray-200">
{{ (string) ($help['short_explanation'] ?? '') }}
</div>
</div>
@if (is_string($help['safe_next_action'] ?? null) && trim((string) ($help['safe_next_action'] ?? '')) !== '')
<x-filament::callout
color="info"
heading="Safe next action"
:description="(string) $help['safe_next_action']"
/>
@endif
@if ($steps !== [])
<ul class="list-disc space-y-1 pl-5 text-sm text-gray-700 dark:text-gray-200">
@foreach ($steps as $step)
@if (is_string($step) && trim($step) !== '')
<li>{{ $step }}</li>
@endif
@endforeach
</ul>
@endif
@if ($links !== [])
<div class="flex flex-wrap gap-2">
@foreach ($links as $link)
@php
$linkUrl = is_string($link['url'] ?? null) && trim((string) ($link['url'] ?? '')) !== ''
? (string) $link['url']
: null;
@endphp
@if ($linkUrl)
<x-filament::button
tag="a"
:href="$linkUrl"
size="sm"
color="primary"
>
{{ (string) ($link['label'] ?? 'Open') }}
</x-filament::button>
@endif
@endforeach
</div>
@endif
</div>
</div>
@endif

View File

@ -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 @@
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
<div class="space-y-4">
<x-filament::section
heading="Verification report"
heading="Stored verification details"
:description="$completedAtLabel ? ('Completed: ' . $completedAtLabel) : 'Stored details for the latest verification operation.'"
>
@if ($runState === 'no_run')
@ -113,28 +108,8 @@
</x-filament::button>
</div>
@if ($showAssist)
<div class="rounded-lg border border-warning-300 bg-warning-50 p-4 shadow-sm dark:border-warning-700 dark:bg-warning-950/40">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<div class="text-sm font-semibold text-warning-950 dark:text-warning-50">
Required permissions assist
</div>
<div class="text-sm text-warning-900 dark:text-warning-100">
{{ $assistDescription }}
</div>
</div>
<x-filament::button
size="sm"
color="warning"
data-testid="verification-assist-trigger"
wire:click="mountAction('{{ $assistActionName }}')"
>
View required permissions
</x-filament::button>
</div>
</div>
@if ($contextualHelp !== null)
@include('filament.components.product-knowledge.contextual-help', ['help' => $contextualHelp])
@endif
@include('filament.components.verification-report-viewer', [

View File

@ -66,6 +66,10 @@
</dl>
</x-filament::section>
@if (is_array($bundle['contextual_help'] ?? null))
@include('filament.components.product-knowledge.contextual-help', ['help' => $bundle['contextual_help']])
@endif
@if ($notes !== [])
<x-filament::section
heading="Support notes"
@ -160,3 +164,4 @@
@endforeach
</div>
</div>

View File

@ -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')

View File

@ -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),
);
}

View File

@ -28,6 +28,7 @@ function buildScanReportService(GraphClientInterface $graphClient): EntraAdminRo
$graphClient,
new HighPrivilegeRoleCatalog,
app(MicrosoftGraphOptionsResolver::class),
app(\App\Support\ProductTelemetry\ProductTelemetryRecorder::class),
);
}

View File

@ -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 {

View File

@ -0,0 +1,265 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\ProductKnowledge\ContextualHelpCatalog;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Verification\VerificationReportWriter;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/**
* @return array{0: User, 1: Tenant, 2: TenantOnboardingSession}
*/
function createProductKnowledgeOnboardingDraft(string $state, string $workspaceRole = 'owner', string $tenantRole = 'owner'): array
{
$tenant = Tenant::factory()->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();
});

View File

@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\ProductUsageEvent;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Services\Onboarding\OnboardingDraftMutationService;
use App\Support\Onboarding\OnboardingCheckpoint;
use App\Support\Onboarding\OnboardingLifecycleState;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('records onboarding checkpoint telemetry after a tenant-linked checkpoint transition persists', function (): void {
$tenant = Tenant::factory()->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);
});

View File

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
use App\Models\ProductUsageEvent;
use App\Models\Tenant;
use App\Services\OperationRunService;
use App\Support\OperationRunType;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('records telemetry for user-initiated tenant-bound operation starts only once per created run', function (): void {
$tenant = Tenant::factory()->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);
});

View File

@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
use App\Jobs\GenerateReviewPackJob;
use App\Models\OperationRun;
use App\Models\ProductUsageEvent;
use App\Models\StoredReport;
use App\Models\Tenant;
use App\Services\EntraAdminRoles\EntraAdminRolesReportService;
use App\Services\EntraAdminRoles\HighPrivilegeRoleCatalog;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\PermissionPosture\PermissionPostureFindingGenerator;
use App\Services\Providers\MicrosoftGraphOptionsResolver;
use App\Services\ReviewPackService;
use App\Support\OperationRunType;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Storage::fake('exports');
});
function productTelemetryRoleDefinitions(): array
{
return [[
'id' => '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']);
});

View File

@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Filament\Pages\TenantDashboard;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
function productKnowledgeSupportDiagnosticsTenantAuthorizationComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
return Livewire::actingAs($user)->test(TenantDashboard::class);
}
function 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();
});

View File

@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Filament\Pages\TenantDashboard;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderVerificationStatus;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
function productKnowledgeTenantSupportDiagnosticsComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
return Livewire::actingAs($user)->test(TenantDashboard::class);
}
function 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],
]);

View File

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Filament\Pages\TenantDashboard;
use App\Models\OperationRun;
use App\Models\ProductUsageEvent;
use App\Models\Tenant;
use App\Models\User;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function tenantDiagnosticsTelemetryComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
return Livewire::actingAs($user)->test(TenantDashboard::class);
}
function 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);
});

View File

@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Filament\Pages\TenantDashboard;
use App\Filament\System\Widgets\ProductTelemetryKpis;
use App\Models\AuditLog;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\ProductUsageEvent;
use App\Models\Tenant;
use App\Support\Auth\PlatformCapabilities;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Filament::setCurrentPanel('system');
Filament::bootCurrentPanel();
});
it('does not emit telemetry or audit rows on passive dashboard and detail renders', 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(),
]);
$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');
});

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
use App\Filament\System\Widgets\ProductTelemetryKpis;
use App\Models\PlatformUser;
use App\Support\Auth\PlatformCapabilities;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Filament::setCurrentPanel('system');
Filament::bootCurrentPanel();
});
it('shows aggregate telemetry to existing dashboard-eligible platform users', function (): void {
$user = PlatformUser::factory()->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();
});

View File

@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
use App\Filament\System\Widgets\ProductTelemetryKpis;
use App\Models\PlatformUser;
use App\Models\ProductUsageEvent;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Auth\PlatformCapabilities;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use App\Support\SystemConsole\SystemConsoleWindow;
use Carbon\CarbonImmutable;
use Filament\Facades\Filament;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Filament::setCurrentPanel('system');
Filament::bootCurrentPanel();
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-04-27 12:00:00'));
});
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
function productTelemetryStats($component): array
{
$method = new ReflectionMethod(ProductTelemetryKpis::class, 'getStats');
$method->setAccessible(true);
return collect($method->invoke($component->instance()))
->mapWithKeys(fn (Stat $stat): array => [
(string) $stat->getLabel() => [
'value' => (string) $stat->getValue(),
'description' => $stat->getDescription(),
],
])
->all();
}
function 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',
]);
});

View File

@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
use App\Models\AuditLog;
use App\Models\OperationRun;
use App\Models\ProductUsageEvent;
use App\Models\ReviewPack;
use App\Models\StoredReport;
use App\Models\Tenant;
use App\Models\User;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('prunes only product usage events older than the configured retention window', function (): void {
config()->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();
});

View File

@ -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]);
});

View File

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
use App\Support\Links\RequiredPermissionsLinks;
use App\Support\ProductKnowledge\ContextualHelpCatalog;
it('exposes the locked first-slice topic catalog and safe knowledge source', function (): void {
$catalog = new ContextualHelpCatalog();
expect($catalog->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);
}
}
});

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
use App\Support\ProductKnowledge\ContextualHelpCatalog;
use App\Support\ProductKnowledge\ContextualHelpResolver;
use App\Support\Providers\ProviderReasonCodes;
it('returns null for blank or unknown topic keys through the fallback contract', function (): void {
$resolver = app(ContextualHelpResolver::class);
expect($resolver->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);
});

View File

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Support\Links\RequiredPermissionsLinks;
use App\Support\OperationRunOutcome;
use App\Support\ProductKnowledge\ContextualHelpCatalog;
use App\Support\ProductKnowledge\ContextualHelpResolver;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function contextualHelpTruthEnvelope(Tenant $tenant): ArtifactTruthEnvelope
{
return new ArtifactTruthEnvelope(
artifactFamily: 'support_diagnostics',
artifactKey: 'tenant_support_diagnostics',
workspaceId: (int) $tenant->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);
});

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Support\ProductTelemetry\ProductTelemetryRecorder;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('records a tenant-owned usage event with the catalog feature area', function () {
$workspace = Workspace::factory()->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');
});

View File

@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Support\ProductTelemetry\ProductTelemetryRecorder;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
dataset('unsafeProductTelemetryMetadata', [
'email address' => ['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');

View File

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
use App\Models\ProductUsageEvent;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Support\ProductTelemetry\ProductTelemetrySummaryQuery;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('summarizes visible families and active workspaces for a selected window', function () {
$user = User::factory()->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]);
});

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
it('exposes the bounded first-slice event catalog', function () {
$catalog = new ProductUsageEventCatalog();
expect($catalog->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');
});

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.