feat: canonical operation type source of truth (#276)
Some checks failed
Main Confidence / confidence (push) Failing after 49s
Some checks failed
Main Confidence / confidence (push) Failing after 49s
## Summary - implement the canonical operation type source-of-truth slice across operation writers, monitoring surfaces, onboarding flows, and supporting services - add focused contract and regression coverage for canonical operation type handling - include the generated spec 239 artifacts for the feature slice ## Validation - browser smoke PASS for `/admin` -> workspace overview -> operations -> operation detail -> tenant-scoped operations drilldown - spec/plan/tasks/quickstart artifact analysis cleaned up to a no-findings state - automated test suite not run in this session Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #276
This commit is contained in:
parent
58f9bb7355
commit
fb32e9bfa5
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -254,6 +254,8 @@ ## Active Technologies
|
|||||||
- Existing PostgreSQL tables such as `provider_connections` and `operation_runs`; one new in-repo config catalog for provider-boundary ownership; no new database tables (237-provider-boundary-hardening)
|
- Existing PostgreSQL tables such as `provider_connections` and `operation_runs`; one new in-repo config catalog for provider-boundary ownership; no new database tables (237-provider-boundary-hardening)
|
||||||
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, `ProviderConnection`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `ProviderConnectionMutationService`, `ProviderConnectionStateProjector`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `BadgeRenderer`, Pest v4 (238-provider-identity-target-scope)
|
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, `ProviderConnection`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `ProviderConnectionMutationService`, `ProviderConnectionStateProjector`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `BadgeRenderer`, Pest v4 (238-provider-identity-target-scope)
|
||||||
- Existing PostgreSQL tables such as `provider_connections`, `provider_credentials`, and existing audit tables; no new database tables planned (238-provider-identity-target-scope)
|
- Existing PostgreSQL tables such as `provider_connections`, `provider_credentials`, and existing audit tables; no new database tables planned (238-provider-identity-target-scope)
|
||||||
|
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing `App\Support\OperationCatalog`, `App\Support\OperationRunType`, `App\Services\OperationRunService`, `App\Services\Providers\ProviderOperationRegistry`, `App\Services\Providers\ProviderOperationStartGate`, `App\Filament\Resources\OperationRunResource`, `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`, `App\Support\Filament\FilterOptionCatalog`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\References\Resolvers\OperationRunReferenceResolver`, `App\Services\Audit\AuditEventBuilder`, Pest v4 (239-canonical-operation-type-source-of-truth)
|
||||||
|
- PostgreSQL via existing `operation_runs.type` and `managed_tenant_onboarding_sessions.state->bootstrap_operation_types`, plus config-backed `tenantpilot.operations.lifecycle.covered_types` and `tenantpilot.platform_vocabulary`; no new tables (239-canonical-operation-type-source-of-truth)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -288,9 +290,9 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
- 239-canonical-operation-type-source-of-truth: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing `App\Support\OperationCatalog`, `App\Support\OperationRunType`, `App\Services\OperationRunService`, `App\Services\Providers\ProviderOperationRegistry`, `App\Services\Providers\ProviderOperationStartGate`, `App\Filament\Resources\OperationRunResource`, `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`, `App\Support\Filament\FilterOptionCatalog`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\References\Resolvers\OperationRunReferenceResolver`, `App\Services\Audit\AuditEventBuilder`, Pest v4
|
||||||
- 238-provider-identity-target-scope: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, `ProviderConnection`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `ProviderConnectionMutationService`, `ProviderConnectionStateProjector`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `BadgeRenderer`, Pest v4
|
- 238-provider-identity-target-scope: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, `ProviderConnection`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `ProviderConnectionMutationService`, `ProviderConnectionStateProjector`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `BadgeRenderer`, Pest v4
|
||||||
- 237-provider-boundary-hardening: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing provider seams under `App\Services\Providers` and `App\Services\Graph`, especially `ProviderGateway`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `MicrosoftGraphOptionsResolver`, `ProviderOperationRegistry`, `ProviderOperationStartGate`, `GraphClientInterface`, Pest v4
|
- 237-provider-boundary-hardening: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing provider seams under `App\Services\Providers` and `App\Services\Graph`, especially `ProviderGateway`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `MicrosoftGraphOptionsResolver`, `ProviderOperationRegistry`, `ProviderOperationStartGate`, `GraphClientInterface`, Pest v4
|
||||||
- 236-canonical-control-catalog-foundation: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing governance domain models and builders, existing Evidence Snapshot and Tenant Review infrastructure
|
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
|
|
||||||
### Pre-production compatibility check
|
### Pre-production compatibility check
|
||||||
|
|||||||
367
.github/skills/spec-kit-next-best-one-shot/SKILL.md
vendored
367
.github/skills/spec-kit-next-best-one-shot/SKILL.md
vendored
@ -1,29 +1,35 @@
|
|||||||
---
|
---
|
||||||
name: spec-kit-next-best-one-shot
|
name: spec-kit-next-best-one-shot
|
||||||
description: Select the most suitable next TenantPilot/TenantAtlas spec from roadmap and spec-candidates, then create Spec Kit preparation artifacts in one pass: spec.md, plan.md, and tasks.md. Use when the user wants the agent to choose the next best spec based on roadmap fit, current candidates, repository state, platform priorities, governance foundations, UX improvements, architecture cleanup, or implementation readiness. This skill must not implement application code.
|
description: Select the most suitable next TenantPilot/TenantAtlas spec from roadmap and spec-candidates, then run the GitHub Spec Kit preparation flow in one pass: specify, plan, tasks, and analyze. Use when the user wants the agent to choose the next best spec, execute the real Spec Kit workflow including branch/spec-directory mechanics, analyze the generated artifacts, and fix preparation issues before implementation. This skill must not implement application code.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Skill: Spec Kit Next-Best One-Shot Preparation
|
# Skill: Spec Kit Next-Best One-Shot Preparation
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Use this skill when the user wants the agent to select the most suitable next spec from existing product planning sources and then create the Spec Kit preparation artifacts in one pass:
|
Use this skill when the user wants the agent to select the most suitable next spec from existing product planning sources and then execute the real GitHub Spec Kit preparation flow in one pass:
|
||||||
|
|
||||||
1. choose the next best spec candidate
|
1. select the next best spec candidate from roadmap and spec candidates
|
||||||
2. create `spec.md`
|
2. run the repository's Spec Kit `specify` flow for that selected candidate
|
||||||
3. create `plan.md`
|
3. run the repository's Spec Kit `plan` flow for the generated spec
|
||||||
4. create `tasks.md`
|
4. run the repository's Spec Kit `tasks` flow for the generated plan
|
||||||
5. provide a manual analysis prompt
|
5. run the repository's Spec Kit `analyze` flow against the generated artifacts
|
||||||
|
6. fix issues in Spec Kit preparation artifacts only (`spec.md`, `plan.md`, `tasks.md`, and related Spec Kit metadata if required)
|
||||||
|
7. stop before implementation
|
||||||
|
8. provide a concise readiness summary for the user
|
||||||
|
|
||||||
This skill prepares implementation work, but it must not perform implementation.
|
This skill must use the repository's actual Spec Kit scripts, commands, templates, branch naming rules, and generated paths. It must not manually bypass Spec Kit by creating arbitrary spec folders or files. The only allowed fixes after `analyze` are preparation-artifact fixes, not application-code implementation.
|
||||||
|
|
||||||
The intended workflow is:
|
The intended workflow is:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
roadmap.md + spec-candidates.md
|
roadmap.md + spec-candidates.md
|
||||||
→ select next best spec
|
→ select next best spec
|
||||||
→ one-shot spec + plan + tasks preparation
|
→ run Spec Kit specify
|
||||||
→ manual repo-based analysis/review
|
→ run Spec Kit plan
|
||||||
|
→ run Spec Kit tasks
|
||||||
|
→ run Spec Kit analyze
|
||||||
|
→ fix preparation-artifact issues
|
||||||
→ explicit implementation step later
|
→ explicit implementation step later
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -32,28 +38,36 @@ ## When to Use
|
|||||||
Use this skill when the user asks things like:
|
Use this skill when the user asks things like:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Nimm die nächste sinnvollste Spec aus roadmap und spec-candidates.
|
Nimm die nächste sinnvollste Spec aus roadmap und spec-candidates und führe specify, plan, tasks und analyze aus.
|
||||||
```
|
```
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Wähle die nächste geeignete Spec und erstelle spec, plan und tasks.
|
Wähle die nächste geeignete Spec und mach den Spec-Kit-Flow inklusive analyze in einem Rutsch.
|
||||||
```
|
```
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Schau in roadmap.md und spec-candidates.md und mach daraus die nächste Spec.
|
Schau in roadmap.md und spec-candidates.md und starte daraus specify, plan, tasks und analyze.
|
||||||
```
|
```
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Such die beste nächste Spec aus und bereite sie in einem Rutsch vor.
|
Such die beste nächste Spec aus und bereite sie per GitHub Spec Kit vollständig vor.
|
||||||
```
|
```
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Nimm angesichts Roadmap und Spec Candidates das sinnvollste nächste Thema.
|
Nimm angesichts Roadmap und Spec Candidates das sinnvollste nächste Thema, aber nicht implementieren.
|
||||||
```
|
```
|
||||||
|
|
||||||
## Hard Rules
|
## Hard Rules
|
||||||
|
|
||||||
- Work strictly repo-based.
|
- Work strictly repo-based.
|
||||||
|
- Use the repository's actual GitHub Spec Kit workflow.
|
||||||
|
- Do not manually invent spec numbers, branch names, or spec paths if Spec Kit provides a script or command for that.
|
||||||
|
- Do not manually create `spec.md`, `plan.md`, or `tasks.md` when the Spec Kit workflow can generate them.
|
||||||
|
- Do not bypass Spec Kit branch mechanics.
|
||||||
|
- Run `analyze` after `tasks` when the repository supports it.
|
||||||
|
- Fix only issues found in Spec Kit preparation artifacts and planning metadata.
|
||||||
|
- Do not treat analyze findings as permission to implement product code.
|
||||||
|
- If analyze reports implementation work as missing, record it in `tasks.md` instead of implementing it.
|
||||||
- Do not implement application code.
|
- Do not implement application code.
|
||||||
- Do not modify production code.
|
- Do not modify production code.
|
||||||
- Do not modify migrations, models, services, jobs, Filament resources, Livewire components, policies, commands, or tests unless the user explicitly starts a later implementation task.
|
- Do not modify migrations, models, services, jobs, Filament resources, Livewire components, policies, commands, or tests unless the user explicitly starts a later implementation task.
|
||||||
@ -67,23 +81,39 @@ ## Hard Rules
|
|||||||
- Preserve TenantPilot/TenantAtlas terminology.
|
- Preserve TenantPilot/TenantAtlas terminology.
|
||||||
- Follow the repository constitution and existing Spec Kit conventions.
|
- Follow the repository constitution and existing Spec Kit conventions.
|
||||||
- If repository truth conflicts with roadmap/candidate wording, keep repository truth and document the deviation.
|
- If repository truth conflicts with roadmap/candidate wording, keep repository truth and document the deviation.
|
||||||
- If no candidate is suitable, create no spec and explain why.
|
- If no candidate is suitable, do not run Spec Kit commands and explain why.
|
||||||
|
|
||||||
## Required Repository Checks
|
## Required Repository Checks Before Selection
|
||||||
|
|
||||||
Before selecting the next spec, inspect:
|
Before selecting the next spec, inspect:
|
||||||
|
|
||||||
1. `.specify/memory/constitution.md`
|
1. `.specify/memory/constitution.md`
|
||||||
2. `.specify/templates/`
|
2. `.specify/templates/`
|
||||||
3. `specs/`
|
3. `.specify/scripts/`
|
||||||
4. `docs/product/spec-candidates.md`
|
4. existing Spec Kit command usage or repository instructions, if present
|
||||||
5. roadmap documents under `docs/product/`, especially `roadmap.md` if present
|
5. `specs/`
|
||||||
6. nearby existing specs related to top candidate areas
|
6. `docs/product/spec-candidates.md`
|
||||||
7. current spec numbering conventions
|
7. roadmap documents under `docs/product/`, especially `roadmap.md` if present
|
||||||
8. application code only as needed to verify whether a candidate is already done, blocked, duplicated, or technically mis-scoped
|
8. nearby existing specs related to top candidate areas
|
||||||
|
9. current branch and git status
|
||||||
|
10. application code only as needed to verify whether a candidate is already done, blocked, duplicated, or technically mis-scoped
|
||||||
|
|
||||||
Do not edit application code.
|
Do not edit application code.
|
||||||
|
|
||||||
|
## Git and Branch Safety
|
||||||
|
|
||||||
|
Before running any Spec Kit command or script:
|
||||||
|
|
||||||
|
1. Check the current branch.
|
||||||
|
2. Check whether the working tree is clean.
|
||||||
|
3. If there are unrelated uncommitted changes, stop and report them. Do not continue.
|
||||||
|
4. If the working tree only contains user-intended planning edits for this operation, continue cautiously.
|
||||||
|
5. Let Spec Kit create or switch to the correct feature branch when that is how the repository workflow works.
|
||||||
|
6. Do not force checkout, reset, stash, rebase, merge, or delete branches.
|
||||||
|
7. Do not overwrite existing specs.
|
||||||
|
|
||||||
|
If the repo requires an explicit branch creation script for `specify`, use that script rather than manually creating the branch.
|
||||||
|
|
||||||
## Candidate Selection Criteria
|
## Candidate Selection Criteria
|
||||||
|
|
||||||
Evaluate candidate specs using these criteria.
|
Evaluate candidate specs using these criteria.
|
||||||
@ -152,213 +182,192 @@ ### 7. User/Product Value
|
|||||||
|
|
||||||
Prefer specs that produce visible operator value or make the platform more sellable without creating heavy scope.
|
Prefer specs that produce visible operator value or make the platform more sellable without creating heavy scope.
|
||||||
|
|
||||||
## Candidate Selection Output
|
## Required Selection Output Before Spec Kit Execution
|
||||||
|
|
||||||
Before creating files, prepare a concise decision summary for the final response.
|
Before running the Spec Kit flow, identify:
|
||||||
|
|
||||||
The selected candidate should include:
|
|
||||||
|
|
||||||
- selected candidate title
|
- selected candidate title
|
||||||
|
- source location in roadmap/spec-candidates
|
||||||
- why it was selected
|
- why it was selected
|
||||||
- why the nearest alternatives were not selected now
|
- why close alternatives were deferred
|
||||||
- roadmap relationship
|
- roadmap relationship
|
||||||
- expected implementation slice
|
- smallest viable implementation slice
|
||||||
|
- proposed concise feature description to feed into `specify`
|
||||||
|
|
||||||
Do not create multiple specs unless the repository convention explicitly supports it and the user asked for it.
|
The feature description must be product- and behavior-oriented. It should not be a low-level implementation plan.
|
||||||
|
|
||||||
## Selection Matrix
|
## Spec Kit Execution Flow
|
||||||
|
|
||||||
When comparing candidates, use a small matrix internally or in the final summary:
|
After selecting the candidate, execute the real repository Spec Kit preparation sequence, including analysis and preparation-artifact fixes.
|
||||||
|
|
||||||
| Candidate | Roadmap fit | Foundation value | Scope size | Repo readiness | Risk reduction | Decision |
|
### Step 1: Determine the repository's Spec Kit command pattern
|
||||||
|---|---:|---:|---:|---:|---:|---|
|
|
||||||
|
|
||||||
Keep it concise. Do not over-analyze if the best candidate is obvious.
|
Inspect repository instructions and scripts to identify how this repo expects Spec Kit to be run.
|
||||||
|
|
||||||
## Spec Directory Rules
|
Common locations to inspect:
|
||||||
|
|
||||||
Create a new spec directory using the next valid spec number and a kebab-case slug:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
specs/<number>-<slug>/
|
.specify/scripts/
|
||||||
|
.specify/templates/
|
||||||
|
.specify/memory/constitution.md
|
||||||
|
.github/prompts/
|
||||||
|
.github/skills/
|
||||||
|
README.md
|
||||||
|
specs/
|
||||||
```
|
```
|
||||||
|
|
||||||
The exact number must be derived from the current repository state and existing numbering conventions.
|
Use the repo-specific mechanism if present.
|
||||||
|
|
||||||
Create or update only these preparation artifacts inside the selected spec directory:
|
### Step 2: Run `specify`
|
||||||
|
|
||||||
```text
|
Run the repository's `specify` flow using the selected candidate and the smallest viable slice.
|
||||||
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 consistent with existing Spec Kit conventions.
|
The `specify` input should include:
|
||||||
|
|
||||||
Do not create implementation files.
|
- selected candidate title
|
||||||
|
- problem statement
|
||||||
|
- operator/user value
|
||||||
|
- roadmap relationship
|
||||||
|
- out-of-scope boundaries
|
||||||
|
- key acceptance criteria
|
||||||
|
- important enterprise constraints
|
||||||
|
|
||||||
## `spec.md` Requirements
|
Let Spec Kit create the correct branch and spec location if that is the repo's configured behavior.
|
||||||
|
|
||||||
The spec must be product- and behavior-oriented.
|
### Step 3: Run `plan`
|
||||||
|
|
||||||
Include:
|
Run the repository's `plan` flow for the generated spec.
|
||||||
|
|
||||||
- Feature title
|
The `plan` input should keep the scope tight and should require repo-based alignment with:
|
||||||
- Selected-candidate rationale
|
|
||||||
- Problem statement
|
|
||||||
- Business/product value
|
|
||||||
- Roadmap relationship
|
|
||||||
- Primary users/operators
|
|
||||||
- User stories
|
|
||||||
- Functional requirements
|
|
||||||
- Non-functional requirements
|
|
||||||
- UX requirements
|
|
||||||
- RBAC/security requirements
|
|
||||||
- Auditability/observability requirements
|
|
||||||
- Data/truth-source requirements where relevant
|
|
||||||
- Out of scope
|
|
||||||
- Acceptance criteria
|
|
||||||
- Success criteria
|
|
||||||
- Risks
|
|
||||||
- Assumptions
|
|
||||||
- Open questions
|
|
||||||
- Follow-up spec candidates if the selected candidate had to be narrowed
|
|
||||||
|
|
||||||
TenantPilot/TenantAtlas specs should preserve enterprise SaaS principles:
|
|
||||||
|
|
||||||
|
- constitution
|
||||||
|
- existing architecture
|
||||||
- workspace/tenant isolation
|
- workspace/tenant isolation
|
||||||
- capability-first RBAC
|
- RBAC
|
||||||
- auditability
|
- OperationRun/observability where relevant
|
||||||
- operation/result truth separation
|
- evidence/snapshot/truth semantics where relevant
|
||||||
- source-of-truth clarity
|
- Filament/Livewire conventions where relevant
|
||||||
- calm enterprise operator UX
|
- test strategy
|
||||||
- progressive disclosure where useful
|
|
||||||
- no false positive calmness
|
|
||||||
- provider/platform boundary clarity where relevant
|
|
||||||
- versioned governance semantics where relevant
|
|
||||||
|
|
||||||
## `plan.md` Requirements
|
### Step 4: Run `tasks`
|
||||||
|
|
||||||
The plan must be repo-aware and implementation-oriented, but must not implement.
|
Run the repository's `tasks` flow for the generated plan.
|
||||||
|
|
||||||
Include:
|
The generated tasks must be:
|
||||||
|
|
||||||
- Technical approach
|
- ordered
|
||||||
- Existing repository surfaces likely affected
|
- small
|
||||||
- Domain/model implications
|
- testable
|
||||||
- UI/Filament implications
|
- grouped by phase
|
||||||
- Livewire implications where relevant
|
- limited to the selected scope
|
||||||
- OperationRun/monitoring implications where relevant
|
- suitable for later manual analysis before implementation
|
||||||
- RBAC/policy implications
|
|
||||||
- Audit/logging/evidence implications where relevant
|
|
||||||
- Data/migration implications where relevant
|
|
||||||
- Test strategy
|
|
||||||
- Rollout considerations
|
|
||||||
- Risk controls
|
|
||||||
- Implementation phases
|
|
||||||
|
|
||||||
Where relevant, clearly distinguish:
|
### Step 5: Run `analyze`
|
||||||
|
|
||||||
- execution truth
|
Run the repository's `analyze` flow against the generated Spec Kit artifacts.
|
||||||
- artifact truth
|
|
||||||
- backup/snapshot truth
|
|
||||||
- evidence truth
|
|
||||||
- recovery confidence
|
|
||||||
- operator next action
|
|
||||||
|
|
||||||
Use those distinctions only when relevant to the selected spec.
|
Analyze must check:
|
||||||
|
|
||||||
## `tasks.md` Requirements
|
- consistency between `spec.md`, `plan.md`, and `tasks.md`
|
||||||
|
- constitution alignment
|
||||||
|
- roadmap alignment
|
||||||
|
- whether the selected candidate was narrowed safely
|
||||||
|
- whether tasks are complete enough for implementation
|
||||||
|
- whether tasks accidentally require scope not described in the spec
|
||||||
|
- whether plan details conflict with repository architecture or terminology
|
||||||
|
- whether implementation risks are documented instead of silently ignored
|
||||||
|
|
||||||
Tasks must be ordered, small, and verifiable.
|
Do not use analyze as a trigger to implement application code.
|
||||||
|
|
||||||
Include:
|
### Step 6: Fix preparation-artifact issues only
|
||||||
|
|
||||||
- checkbox tasks
|
If analyze finds issues, fix only Spec Kit preparation artifacts such as:
|
||||||
- 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:
|
- `spec.md`
|
||||||
|
- `plan.md`
|
||||||
|
- `tasks.md`
|
||||||
|
- generated Spec Kit metadata files, if the repository uses them
|
||||||
|
|
||||||
```text
|
Allowed fixes include:
|
||||||
Clean up code
|
|
||||||
Refactor UI
|
|
||||||
Improve performance
|
|
||||||
Make it enterprise-ready
|
|
||||||
```
|
|
||||||
|
|
||||||
Prefer concrete tasks such as:
|
- clarify requirements
|
||||||
|
- tighten scope
|
||||||
|
- move out-of-scope work into follow-up candidates
|
||||||
|
- correct terminology
|
||||||
|
- add missing tasks
|
||||||
|
- remove tasks not backed by the spec
|
||||||
|
- align plan language with repository architecture
|
||||||
|
- add missing acceptance criteria or validation tasks
|
||||||
|
|
||||||
```text
|
Forbidden fixes include:
|
||||||
- [ ] 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.
|
- modifying application code
|
||||||
|
- creating migrations
|
||||||
|
- editing models, services, jobs, policies, Filament resources, Livewire components, tests, or commands
|
||||||
|
- running implementation or test-fix loops
|
||||||
|
- changing runtime behavior
|
||||||
|
|
||||||
## Scope Control
|
### Step 7: Stop
|
||||||
|
|
||||||
If the selected roadmap/candidate item is too broad, narrow it into the smallest valuable first implementation slice.
|
After `analyze` has passed or preparation-artifact issues have been fixed, stop.
|
||||||
|
|
||||||
Add a `Follow-up spec candidates` section for deferred concerns.
|
Do not implement.
|
||||||
|
Do not modify application code.
|
||||||
|
Do not run implementation tests unless the repository's Spec Kit preparation command requires a non-destructive validation.
|
||||||
|
|
||||||
Examples of follow-up candidates:
|
## Failure Handling
|
||||||
|
|
||||||
- assigned findings
|
If a Spec Kit command or analyze phase fails:
|
||||||
- pending approvals
|
|
||||||
- personal work queue
|
|
||||||
- notification delivery settings
|
|
||||||
- evidence pack export hardening
|
|
||||||
- operation monitoring refinements
|
|
||||||
- autonomous governance decision surfaces
|
|
||||||
- compliance mapping library expansion
|
|
||||||
- MSP portfolio rollups
|
|
||||||
- provider-specific adapters
|
|
||||||
|
|
||||||
Do not force follow-up candidates into the primary spec.
|
1. Stop immediately.
|
||||||
|
2. Report the failing command or phase.
|
||||||
|
3. Summarize the error.
|
||||||
|
4. Do not attempt implementation as a workaround.
|
||||||
|
5. Suggest the smallest safe next action.
|
||||||
|
|
||||||
|
If the branch or working tree state is unsafe:
|
||||||
|
|
||||||
|
1. Stop before running Spec Kit commands.
|
||||||
|
2. Report the current branch and relevant uncommitted files.
|
||||||
|
3. Ask the user to commit, stash, or move to a clean worktree.
|
||||||
|
|
||||||
## Final Response Requirements
|
## Final Response Requirements
|
||||||
|
|
||||||
After creating or updating the artifacts, respond with:
|
After the Spec Kit preparation flow completes, respond with:
|
||||||
|
|
||||||
1. Selected candidate
|
1. Selected candidate
|
||||||
2. Why this candidate was selected
|
2. Why this candidate was selected
|
||||||
3. Why close alternatives were deferred
|
3. Why close alternatives were deferred
|
||||||
4. Created or updated spec directory
|
4. Current branch after Spec Kit execution
|
||||||
5. Files created or updated
|
5. Generated spec path
|
||||||
6. Important repo-based adjustments made
|
6. Files created or updated by Spec Kit
|
||||||
7. Assumptions made
|
7. Analyze result summary
|
||||||
8. Open questions, if any
|
8. Preparation-artifact fixes applied after analyze
|
||||||
9. Recommended next manual analysis prompt
|
9. Assumptions made
|
||||||
10. Explicit statement that no implementation was performed
|
10. Open questions, if any
|
||||||
|
11. Recommended next implementation prompt
|
||||||
|
12. Explicit statement that no application implementation was performed
|
||||||
|
|
||||||
Keep the response concise, but include enough detail for the user to continue immediately.
|
Keep the response concise, but include enough detail for the user to continue immediately.
|
||||||
|
|
||||||
## Required Next Manual Analysis Prompt
|
## Required Next Implementation Prompt
|
||||||
|
|
||||||
Always provide a ready-to-copy prompt like this, adapted to the created spec number and slug:
|
Always provide a ready-to-copy implementation prompt like this, adapted to the generated spec branch/path, but only after analyze has passed or preparation-artifact issues have been fixed:
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
Du bist ein Senior Staff Software Architect und Enterprise SaaS Reviewer.
|
Du bist ein Senior Staff Software Engineer für TenantPilot/TenantAtlas.
|
||||||
|
|
||||||
Analysiere die neu erstellte Spec `<spec-number>-<slug>` streng repo-basiert.
|
Implementiere die vorbereitete Spec `<spec-branch-or-spec-path>` streng anhand von `tasks.md`.
|
||||||
|
|
||||||
Ziel:
|
|
||||||
Prüfe, ob `spec.md`, `plan.md` und `tasks.md` vollständig, konsistent, implementierbar, roadmap-konform und constitution-konform sind.
|
|
||||||
|
|
||||||
Wichtig:
|
Wichtig:
|
||||||
- Keine Implementierung.
|
- Arbeite task-sequenziell.
|
||||||
- Keine Codeänderungen.
|
- Ä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 Scope-Erweiterung.
|
||||||
- Prüfe nur gegen Repo-Wahrheit.
|
- Keine Opportunistic Refactors.
|
||||||
- Prüfe auch, ob die ausgewählte Spec wirklich die sinnvollste nächste Spec aus `docs/product/spec-candidates.md` und `docs/product/roadmap.md` war.
|
- Führe passende Tests nach sinnvollen Task-Gruppen aus.
|
||||||
- Benenne konkrete Konflikte mit Dateien, Patterns, Datenflüssen oder bestehenden Specs.
|
- Wenn eine Task unklar oder falsch ist, stoppe und dokumentiere den Konflikt statt frei zu improvisieren.
|
||||||
- Schlage nur minimale Korrekturen an `spec.md`, `plan.md` und `tasks.md` vor.
|
- Am Ende liefere geänderte Dateien, Teststatus, offene Risiken und nicht erledigte Tasks.
|
||||||
- Wenn alles passt, gib eine klare Implementierungsfreigabe.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Example Invocation
|
## Example Invocation
|
||||||
@ -367,17 +376,23 @@ ## Example Invocation
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
Nutze den Skill spec-kit-next-best-one-shot.
|
Nutze den Skill spec-kit-next-best-one-shot.
|
||||||
Wähle aus roadmap.md und spec-candidates.md die nächste sinnvollste Spec und erstelle spec, plan und tasks in einem Rutsch.
|
Wähle aus roadmap.md und spec-candidates.md die nächste sinnvollste Spec.
|
||||||
Keine Implementierung.
|
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:
|
Expected behavior:
|
||||||
|
|
||||||
1. Inspect constitution, templates, specs, roadmap, and spec candidates.
|
1. Inspect constitution, Spec Kit scripts/templates, specs, roadmap, and spec candidates.
|
||||||
2. Compare candidate suitability.
|
2. Check branch and working tree safety.
|
||||||
3. Select the next best candidate.
|
3. Compare candidate suitability.
|
||||||
4. Determine the next valid spec number.
|
4. Select the next best candidate.
|
||||||
5. Create `spec.md`, `plan.md`, and `tasks.md`.
|
5. Run the repository's real Spec Kit `specify` flow, letting it handle branch/spec setup.
|
||||||
6. Keep scope tight.
|
6. Run the repository's real Spec Kit `plan` flow.
|
||||||
7. Do not implement.
|
7. Run the repository's real Spec Kit `tasks` flow.
|
||||||
8. Return selection rationale, artifact summary, and next manual analysis prompt.
|
8. Run the repository's real Spec Kit `analyze` flow.
|
||||||
|
9. Fix analyze issues only in Spec Kit preparation artifacts.
|
||||||
|
10. Stop before application implementation.
|
||||||
|
11. Return selection rationale, branch/path summary, artifact summary, analyze summary, fixes applied, and next implementation prompt.
|
||||||
|
```
|
||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
@ -50,7 +51,7 @@ public function handle(): int
|
|||||||
$opService = app(OperationRunService::class);
|
$opService = app(OperationRunService::class);
|
||||||
$opRun = $opService->ensureRunWithIdentityStrict(
|
$opRun = $opService->ensureRunWithIdentityStrict(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'entra_group_sync',
|
type: OperationRunType::DirectoryGroupsSync->value,
|
||||||
identityInputs: [
|
identityInputs: [
|
||||||
'selection_key' => $selectionKey,
|
'selection_key' => $selectionKey,
|
||||||
'slot_key' => $slotKey,
|
'slot_key' => $slotKey,
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@ -168,12 +169,12 @@ private function recordPurgeOperationRun(Tenant $tenant, array $counts): void
|
|||||||
'tenant_id' => (int) $tenant->id,
|
'tenant_id' => (int) $tenant->id,
|
||||||
'user_id' => null,
|
'user_id' => null,
|
||||||
'initiator_name' => 'System',
|
'initiator_name' => 'System',
|
||||||
'type' => 'backup_schedule_purge',
|
'type' => OperationRunType::BackupSchedulePurge->value,
|
||||||
'status' => 'completed',
|
'status' => 'completed',
|
||||||
'outcome' => 'succeeded',
|
'outcome' => 'succeeded',
|
||||||
'run_identity_hash' => hash('sha256', implode(':', [
|
'run_identity_hash' => hash('sha256', implode(':', [
|
||||||
(string) $tenant->id,
|
(string) $tenant->id,
|
||||||
'backup_schedule_purge',
|
OperationRunType::BackupSchedulePurge->value,
|
||||||
now()->toISOString(),
|
now()->toISOString(),
|
||||||
Str::uuid()->toString(),
|
Str::uuid()->toString(),
|
||||||
])),
|
])),
|
||||||
|
|||||||
@ -7,7 +7,9 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Operations\OperationLifecycleReconciler;
|
use App\Services\Operations\OperationLifecycleReconciler;
|
||||||
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
class TenantpilotReconcileBackupScheduleOperationRuns extends Command
|
class TenantpilotReconcileBackupScheduleOperationRuns extends Command
|
||||||
@ -28,7 +30,7 @@ public function handle(
|
|||||||
$dryRun = (bool) $this->option('dry-run');
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
|
||||||
$query = OperationRun::query()
|
$query = OperationRun::query()
|
||||||
->where('type', 'backup_schedule_run')
|
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BackupScheduleExecute->value))
|
||||||
->whereIn('status', ['queued', 'running']);
|
->whereIn('status', ['queued', 'running']);
|
||||||
|
|
||||||
if ($olderThanMinutes > 0) {
|
if ($olderThanMinutes > 0) {
|
||||||
|
|||||||
@ -21,6 +21,7 @@
|
|||||||
use App\Support\Baselines\TenantGovernanceAggregate;
|
use App\Support\Baselines\TenantGovernanceAggregate;
|
||||||
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
@ -489,7 +490,7 @@ private function compareNowAction(): Action
|
|||||||
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast($run instanceof OperationRun ? (string) $run->type : 'baseline_compare')
|
OperationUxPresenter::queuedToast($run instanceof OperationRun ? (string) $run->type : OperationRunType::BaselineCompare->value)
|
||||||
->actions($run instanceof OperationRun ? [
|
->actions($run instanceof OperationRun ? [
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
->label('Open operation')
|
->label('Open operation')
|
||||||
|
|||||||
@ -18,6 +18,7 @@
|
|||||||
use App\Support\Baselines\Compare\CompareStrategyRegistry;
|
use App\Support\Baselines\Compare\CompareStrategyRegistry;
|
||||||
use App\Support\Navigation\CanonicalNavigationContext;
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||||
@ -810,8 +811,8 @@ private function compareAssignedTenants(): void
|
|||||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
|
|
||||||
$toast = (int) $result['queuedCount'] > 0
|
$toast = (int) $result['queuedCount'] > 0
|
||||||
? OperationUxPresenter::queuedToast('baseline_compare')
|
? OperationUxPresenter::queuedToast(OperationRunType::BaselineCompare->value)
|
||||||
: OperationUxPresenter::alreadyQueuedToast('baseline_compare');
|
: OperationUxPresenter::alreadyQueuedToast(OperationRunType::BaselineCompare->value);
|
||||||
|
|
||||||
$toast
|
$toast
|
||||||
->body($summary.' Open Operations for progress and next steps.')
|
->body($summary.' Open Operations for progress and next steps.')
|
||||||
|
|||||||
@ -21,6 +21,7 @@
|
|||||||
use App\Support\Inventory\TenantCoverageTruth;
|
use App\Support\Inventory\TenantCoverageTruth;
|
||||||
use App\Support\Inventory\TenantCoverageTruthResolver;
|
use App\Support\Inventory\TenantCoverageTruthResolver;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
@ -561,6 +562,6 @@ protected function coverageTruth(): ?TenantCoverageTruth
|
|||||||
|
|
||||||
private function inventorySyncHistoryUrl(Tenant $tenant): string
|
private function inventorySyncHistoryUrl(Tenant $tenant): string
|
||||||
{
|
{
|
||||||
return OperationRunLinks::index($tenant, operationType: 'inventory_sync');
|
return OperationRunLinks::index($tenant, operationType: OperationRunType::InventorySync->value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,7 +16,9 @@
|
|||||||
use App\Support\Navigation\CanonicalNavigationContext;
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
use App\Support\Navigation\RelatedNavigationResolver;
|
use App\Support\Navigation\RelatedNavigationResolver;
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\OpsUx\RunDetailPolling;
|
use App\Support\OpsUx\RunDetailPolling;
|
||||||
@ -507,12 +509,14 @@ private function canResumeCapture(): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! in_array((string) $this->run->type, ['baseline_capture', 'baseline_compare'], true)) {
|
$canonicalType = OperationCatalog::canonicalCode((string) $this->run->type);
|
||||||
|
|
||||||
|
if (! in_array($canonicalType, [OperationRunType::BaselineCapture->value, OperationRunType::BaselineCompare->value], true)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$context = is_array($this->run->context) ? $this->run->context : [];
|
$context = is_array($this->run->context) ? $this->run->context : [];
|
||||||
$tokenKey = (string) $this->run->type === 'baseline_capture'
|
$tokenKey = $canonicalType === OperationRunType::BaselineCapture->value
|
||||||
? 'baseline_capture.resume_token'
|
? 'baseline_capture.resume_token'
|
||||||
: 'baseline_compare.resume_token';
|
: 'baseline_compare.resume_token';
|
||||||
$token = data_get($context, $tokenKey);
|
$token = data_get($context, $tokenKey);
|
||||||
|
|||||||
@ -42,6 +42,7 @@
|
|||||||
use App\Support\Onboarding\OnboardingCheckpoint;
|
use App\Support\Onboarding\OnboardingCheckpoint;
|
||||||
use App\Support\Onboarding\OnboardingDraftStage;
|
use App\Support\Onboarding\OnboardingDraftStage;
|
||||||
use App\Support\Onboarding\OnboardingLifecycleState;
|
use App\Support\Onboarding\OnboardingLifecycleState;
|
||||||
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
@ -767,7 +768,7 @@ private function normalizeBootstrapOperationTypes(array $operationTypes): array
|
|||||||
|
|
||||||
foreach ($operationTypes as $key => $value) {
|
foreach ($operationTypes as $key => $value) {
|
||||||
if (is_string($value)) {
|
if (is_string($value)) {
|
||||||
$normalizedValue = trim($value);
|
$normalizedValue = $this->normalizeBootstrapOperationType($value);
|
||||||
|
|
||||||
if ($normalizedValue !== '' && in_array($normalizedValue, $supportedTypes, true)) {
|
if ($normalizedValue !== '' && in_array($normalizedValue, $supportedTypes, true)) {
|
||||||
$normalized[] = $normalizedValue;
|
$normalized[] = $normalizedValue;
|
||||||
@ -787,7 +788,7 @@ private function normalizeBootstrapOperationTypes(array $operationTypes): array
|
|||||||
default => false,
|
default => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
$normalizedKey = trim($key);
|
$normalizedKey = $this->normalizeBootstrapOperationType($key);
|
||||||
|
|
||||||
if ($isSelected && in_array($normalizedKey, $supportedTypes, true)) {
|
if ($isSelected && in_array($normalizedKey, $supportedTypes, true)) {
|
||||||
$normalized[] = $normalizedKey;
|
$normalized[] = $normalizedKey;
|
||||||
@ -797,13 +798,24 @@ private function normalizeBootstrapOperationTypes(array $operationTypes): array
|
|||||||
return array_values(array_unique($normalized));
|
return array_values(array_unique($normalized));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function normalizeBootstrapOperationType(string $operationType): string
|
||||||
|
{
|
||||||
|
$operationType = trim($operationType);
|
||||||
|
|
||||||
|
if ($operationType === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return OperationCatalog::canonicalCode($operationType);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
private function supportedBootstrapCapabilities(): array
|
private function supportedBootstrapCapabilities(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'inventory_sync' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
|
'inventory.sync' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
|
||||||
'compliance.snapshot' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC,
|
'compliance.snapshot' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -3343,7 +3355,7 @@ private function dispatchBootstrapJob(
|
|||||||
OperationRun $run,
|
OperationRun $run,
|
||||||
): void {
|
): void {
|
||||||
match ($operationType) {
|
match ($operationType) {
|
||||||
'inventory_sync' => ProviderInventorySyncJob::dispatch(
|
'inventory.sync' => ProviderInventorySyncJob::dispatch(
|
||||||
tenantId: $tenantId,
|
tenantId: $tenantId,
|
||||||
userId: $userId,
|
userId: $userId,
|
||||||
providerConnectionId: $providerConnectionId,
|
providerConnectionId: $providerConnectionId,
|
||||||
|
|||||||
@ -25,6 +25,7 @@
|
|||||||
use App\Support\Filament\TablePaginationProfiles;
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
@ -457,7 +458,7 @@ public static function table(Table $table): Table
|
|||||||
$nonce = (string) Str::uuid();
|
$nonce = (string) Str::uuid();
|
||||||
$operationRun = $operationRunService->ensureRunWithIdentity(
|
$operationRun = $operationRunService->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'backup_schedule_run',
|
type: OperationRunType::BackupScheduleExecute->value,
|
||||||
identityInputs: [
|
identityInputs: [
|
||||||
'backup_schedule_id' => (int) $record->getKey(),
|
'backup_schedule_id' => (int) $record->getKey(),
|
||||||
'nonce' => $nonce,
|
'nonce' => $nonce,
|
||||||
@ -528,7 +529,7 @@ public static function table(Table $table): Table
|
|||||||
$nonce = (string) Str::uuid();
|
$nonce = (string) Str::uuid();
|
||||||
$operationRun = $operationRunService->ensureRunWithIdentity(
|
$operationRun = $operationRunService->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'backup_schedule_run',
|
type: OperationRunType::BackupScheduleExecute->value,
|
||||||
identityInputs: [
|
identityInputs: [
|
||||||
'backup_schedule_id' => (int) $record->getKey(),
|
'backup_schedule_id' => (int) $record->getKey(),
|
||||||
'nonce' => $nonce,
|
'nonce' => $nonce,
|
||||||
@ -755,7 +756,7 @@ public static function table(Table $table): Table
|
|||||||
$nonce = (string) Str::uuid();
|
$nonce = (string) Str::uuid();
|
||||||
$operationRun = $operationRunService->ensureRunWithIdentity(
|
$operationRun = $operationRunService->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'backup_schedule_run',
|
type: OperationRunType::BackupScheduleExecute->value,
|
||||||
identityInputs: [
|
identityInputs: [
|
||||||
'backup_schedule_id' => (int) $record->getKey(),
|
'backup_schedule_id' => (int) $record->getKey(),
|
||||||
'nonce' => $nonce,
|
'nonce' => $nonce,
|
||||||
@ -852,7 +853,7 @@ public static function table(Table $table): Table
|
|||||||
$nonce = (string) Str::uuid();
|
$nonce = (string) Str::uuid();
|
||||||
$operationRun = $operationRunService->ensureRunWithIdentity(
|
$operationRun = $operationRunService->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'backup_schedule_run',
|
type: OperationRunType::BackupScheduleExecute->value,
|
||||||
identityInputs: [
|
identityInputs: [
|
||||||
'backup_schedule_id' => (int) $record->getKey(),
|
'backup_schedule_id' => (int) $record->getKey(),
|
||||||
'nonce' => $nonce,
|
'nonce' => $nonce,
|
||||||
|
|||||||
@ -32,6 +32,8 @@
|
|||||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||||
use App\Support\Navigation\RelatedContextEntry;
|
use App\Support\Navigation\RelatedContextEntry;
|
||||||
use App\Support\Navigation\RelatedNavigationResolver;
|
use App\Support\Navigation\RelatedNavigationResolver;
|
||||||
|
use App\Support\OperationCatalog;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||||
@ -873,7 +875,7 @@ private static function latestBaselineCaptureEnvelope(BaselineProfile $profile):
|
|||||||
{
|
{
|
||||||
$run = OperationRun::query()
|
$run = OperationRun::query()
|
||||||
->where('workspace_id', (int) $profile->workspace_id)
|
->where('workspace_id', (int) $profile->workspace_id)
|
||||||
->where('type', 'baseline_capture')
|
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BaselineCapture->value))
|
||||||
->where('context->baseline_profile_id', (int) $profile->getKey())
|
->where('context->baseline_profile_id', (int) $profile->getKey())
|
||||||
->where('status', 'completed')
|
->where('status', 'completed')
|
||||||
->orderByDesc('completed_at')
|
->orderByDesc('completed_at')
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
@ -340,8 +341,8 @@ private function compareAssignedTenantsAction(): Action
|
|||||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
|
|
||||||
$toast = (int) $result['queuedCount'] > 0
|
$toast = (int) $result['queuedCount'] > 0
|
||||||
? OperationUxPresenter::queuedToast('baseline_compare')
|
? OperationUxPresenter::queuedToast(OperationRunType::BaselineCompare->value)
|
||||||
: OperationUxPresenter::alreadyQueuedToast('baseline_compare');
|
: OperationUxPresenter::alreadyQueuedToast(OperationRunType::BaselineCompare->value);
|
||||||
|
|
||||||
$toast
|
$toast
|
||||||
->body($summary.' Open Operations for progress and next steps.')
|
->body($summary.' Open Operations for progress and next steps.')
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
@ -175,7 +176,7 @@ protected function getHeaderActions(): array
|
|||||||
$opService = app(OperationRunService::class);
|
$opService = app(OperationRunService::class);
|
||||||
$opRun = $opService->ensureRunWithIdentity(
|
$opRun = $opService->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'inventory_sync',
|
type: OperationRunType::InventorySync->value,
|
||||||
identityInputs: [
|
identityInputs: [
|
||||||
'selection_hash' => $computed['selection_hash'],
|
'selection_hash' => $computed['selection_hash'],
|
||||||
],
|
],
|
||||||
|
|||||||
@ -27,6 +27,7 @@
|
|||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\RunDurationInsights;
|
use App\Support\OpsUx\RunDurationInsights;
|
||||||
use App\Support\OpsUx\SummaryCountsNormalizer;
|
use App\Support\OpsUx\SummaryCountsNormalizer;
|
||||||
@ -230,7 +231,9 @@ public static function table(Table $table): Table
|
|||||||
return $query;
|
return $query;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $query->whereIn('type', OperationCatalog::rawValuesForCanonical($value));
|
return $query->whereIn('type', OperationCatalog::rawValuesForCanonical(
|
||||||
|
OperationCatalog::canonicalCode($value),
|
||||||
|
));
|
||||||
}),
|
}),
|
||||||
Tables\Filters\SelectFilter::make('status')
|
Tables\Filters\SelectFilter::make('status')
|
||||||
->options(BadgeCatalog::options(BadgeDomain::OperationRunStatus, OperationRunStatus::values())),
|
->options(BadgeCatalog::options(BadgeDomain::OperationRunStatus, OperationRunStatus::values())),
|
||||||
@ -411,7 +414,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((string) $record->type === 'baseline_compare') {
|
if (OperationCatalog::canonicalCode((string) $record->type) === OperationRunType::BaselineCompare->value) {
|
||||||
$baselineCompareFacts = static::baselineCompareFacts($record, $factory);
|
$baselineCompareFacts = static::baselineCompareFacts($record, $factory);
|
||||||
$baselineCompareEvidence = static::baselineCompareEvidencePayload($record);
|
$baselineCompareEvidence = static::baselineCompareEvidencePayload($record);
|
||||||
$gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($record);
|
$gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($record);
|
||||||
@ -466,7 +469,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((string) $record->type === 'baseline_capture') {
|
if (OperationCatalog::canonicalCode((string) $record->type) === OperationRunType::BaselineCapture->value) {
|
||||||
$baselineCaptureEvidence = static::baselineCaptureEvidencePayload($record);
|
$baselineCaptureEvidence = static::baselineCaptureEvidencePayload($record);
|
||||||
|
|
||||||
if ($baselineCaptureEvidence !== []) {
|
if ($baselineCaptureEvidence !== []) {
|
||||||
@ -1446,7 +1449,7 @@ private static function reconciliationPayload(OperationRun $record): array
|
|||||||
*/
|
*/
|
||||||
private static function inventorySyncCoverageSection(OperationRun $record): ?array
|
private static function inventorySyncCoverageSection(OperationRun $record): ?array
|
||||||
{
|
{
|
||||||
if ((string) $record->type !== 'inventory_sync') {
|
if (OperationCatalog::canonicalCode((string) $record->type) !== OperationRunType::InventorySync->value) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,7 @@
|
|||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
|
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
@ -949,7 +950,7 @@ public static function makeInventorySyncAction(): Actions\Action
|
|||||||
static::handleProviderOperationAction(
|
static::handleProviderOperationAction(
|
||||||
record: $record,
|
record: $record,
|
||||||
gate: $gate,
|
gate: $gate,
|
||||||
operationType: 'inventory_sync',
|
operationType: OperationRunType::InventorySync->value,
|
||||||
blockedTitle: 'Inventory sync blocked',
|
blockedTitle: 'Inventory sync blocked',
|
||||||
dispatcher: function (Tenant $tenant, User $user, ProviderConnection $connection, OperationRun $operationRun): void {
|
dispatcher: function (Tenant $tenant, User $user, ProviderConnection $connection, OperationRun $operationRun): void {
|
||||||
ProviderInventorySyncJob::dispatch(
|
ProviderInventorySyncJob::dispatch(
|
||||||
|
|||||||
@ -14,7 +14,9 @@
|
|||||||
use App\Support\Inventory\InventoryKpiBadges;
|
use App\Support\Inventory\InventoryKpiBadges;
|
||||||
use App\Support\Inventory\TenantCoverageTruth;
|
use App\Support\Inventory\TenantCoverageTruth;
|
||||||
use App\Support\Inventory\TenantCoverageTruthResolver;
|
use App\Support\Inventory\TenantCoverageTruthResolver;
|
||||||
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
use Filament\Widgets\StatsOverviewWidget;
|
use Filament\Widgets\StatsOverviewWidget;
|
||||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||||
use Illuminate\Support\Facades\Blade;
|
use Illuminate\Support\Facades\Blade;
|
||||||
@ -56,7 +58,7 @@ protected function getStats(): array
|
|||||||
|
|
||||||
$inventoryOps = (int) OperationRun::query()
|
$inventoryOps = (int) OperationRun::query()
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
->where('type', 'inventory_sync')
|
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::InventorySync->value))
|
||||||
->active()
|
->active()
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
|
|||||||
@ -8,8 +8,10 @@
|
|||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Settings\SettingsResolver;
|
use App\Services\Settings\SettingsResolver;
|
||||||
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
@ -36,10 +38,10 @@ public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResol
|
|||||||
'tenant_id' => (int) $schedule->tenant_id,
|
'tenant_id' => (int) $schedule->tenant_id,
|
||||||
'user_id' => null,
|
'user_id' => null,
|
||||||
'initiator_name' => 'System',
|
'initiator_name' => 'System',
|
||||||
'type' => 'backup_schedule_retention',
|
'type' => OperationRunType::BackupScheduleRetention->value,
|
||||||
'status' => OperationRunStatus::Running->value,
|
'status' => OperationRunStatus::Running->value,
|
||||||
'outcome' => OperationRunOutcome::Pending->value,
|
'outcome' => OperationRunOutcome::Pending->value,
|
||||||
'run_identity_hash' => hash('sha256', (string) $schedule->tenant_id.':backup_schedule_retention:'.$schedule->id.':'.Str::uuid()->toString()),
|
'run_identity_hash' => hash('sha256', (string) $schedule->tenant_id.':'.OperationRunType::BackupScheduleRetention->value.':'.$schedule->id.':'.Str::uuid()->toString()),
|
||||||
'context' => [
|
'context' => [
|
||||||
'backup_schedule_id' => (int) $schedule->id,
|
'backup_schedule_id' => (int) $schedule->id,
|
||||||
],
|
],
|
||||||
@ -88,7 +90,7 @@ public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResol
|
|||||||
/** @var Collection<int, int> $keepBackupSetIds */
|
/** @var Collection<int, int> $keepBackupSetIds */
|
||||||
$keepBackupSetIds = OperationRun::query()
|
$keepBackupSetIds = OperationRun::query()
|
||||||
->where('tenant_id', (int) $schedule->tenant_id)
|
->where('tenant_id', (int) $schedule->tenant_id)
|
||||||
->where('type', 'backup_schedule_run')
|
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BackupScheduleExecute->value))
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
->where('context->backup_schedule_id', (int) $schedule->id)
|
->where('context->backup_schedule_id', (int) $schedule->id)
|
||||||
->whereNotNull('context->backup_set_id')
|
->whereNotNull('context->backup_set_id')
|
||||||
@ -103,7 +105,7 @@ public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResol
|
|||||||
/** @var Collection<int, int> $allBackupSetIds */
|
/** @var Collection<int, int> $allBackupSetIds */
|
||||||
$allBackupSetIds = OperationRun::query()
|
$allBackupSetIds = OperationRun::query()
|
||||||
->where('tenant_id', (int) $schedule->tenant_id)
|
->where('tenant_id', (int) $schedule->tenant_id)
|
||||||
->where('type', 'backup_schedule_run')
|
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BackupScheduleExecute->value))
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
->where('context->backup_schedule_id', (int) $schedule->id)
|
->where('context->backup_schedule_id', (int) $schedule->id)
|
||||||
->whereNotNull('context->backup_set_id')
|
->whereNotNull('context->backup_set_id')
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Support\OperationCatalog;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@ -34,11 +36,11 @@ public function tenant(): BelongsTo
|
|||||||
public function operationRuns(): HasMany
|
public function operationRuns(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(OperationRun::class, 'tenant_id', 'tenant_id')
|
return $this->hasMany(OperationRun::class, 'tenant_id', 'tenant_id')
|
||||||
->whereIn('type', [
|
->whereIn('type', array_values(array_unique(array_merge(
|
||||||
'backup_schedule_run',
|
OperationCatalog::rawValuesForCanonical(OperationRunType::BackupScheduleExecute->value),
|
||||||
'backup_schedule_retention',
|
OperationCatalog::rawValuesForCanonical(OperationRunType::BackupScheduleRetention->value),
|
||||||
'backup_schedule_purge',
|
OperationCatalog::rawValuesForCanonical(OperationRunType::BackupSchedulePurge->value),
|
||||||
])
|
))))
|
||||||
->where('context->backup_schedule_id', (int) $this->getKey());
|
->where('context->backup_schedule_id', (int) $this->getKey());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -98,7 +98,7 @@ public function scopeBaselineCompareForProfile(Builder $query, BaselineProfile|i
|
|||||||
: (int) $profile;
|
: (int) $profile;
|
||||||
|
|
||||||
return $query
|
return $query
|
||||||
->where('type', OperationRunType::BaselineCompare->value)
|
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BaselineCompare->value))
|
||||||
->where('context->baseline_profile_id', $profileId);
|
->where('context->baseline_profile_id', $profileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,7 +112,7 @@ public function scopeLikelyStale(Builder $query, ?OperationLifecyclePolicy $poli
|
|||||||
foreach ($policy->coveredTypeNames() as $type) {
|
foreach ($policy->coveredTypeNames() as $type) {
|
||||||
$query->orWhere(function (Builder $typeQuery) use ($policy, $type): void {
|
$query->orWhere(function (Builder $typeQuery) use ($policy, $type): void {
|
||||||
$typeQuery
|
$typeQuery
|
||||||
->where('type', $type)
|
->whereIn('type', OperationCatalog::rawValuesForCanonical($type))
|
||||||
->where(function (Builder $stateQuery) use ($policy, $type): void {
|
->where(function (Builder $stateQuery) use ($policy, $type): void {
|
||||||
$stateQuery
|
$stateQuery
|
||||||
->where(function (Builder $queuedQuery) use ($policy, $type): void {
|
->where(function (Builder $queuedQuery) use ($policy, $type): void {
|
||||||
@ -152,12 +152,18 @@ public function scopeHealthyActive(Builder $query, ?OperationLifecyclePolicy $po
|
|||||||
return $query
|
return $query
|
||||||
->active()
|
->active()
|
||||||
->where(function (Builder $query) use ($coveredTypes, $policy): void {
|
->where(function (Builder $query) use ($coveredTypes, $policy): void {
|
||||||
$query->whereNotIn('type', $coveredTypes);
|
$coveredRawTypes = collect($coveredTypes)
|
||||||
|
->flatMap(static fn (string $type): array => OperationCatalog::rawValuesForCanonical($type))
|
||||||
|
->unique()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$query->whereNotIn('type', $coveredRawTypes);
|
||||||
|
|
||||||
foreach ($coveredTypes as $type) {
|
foreach ($coveredTypes as $type) {
|
||||||
$query->orWhere(function (Builder $typeQuery) use ($policy, $type): void {
|
$query->orWhere(function (Builder $typeQuery) use ($policy, $type): void {
|
||||||
$typeQuery
|
$typeQuery
|
||||||
->where('type', $type)
|
->whereIn('type', OperationCatalog::rawValuesForCanonical($type))
|
||||||
->where(function (Builder $stateQuery) use ($policy, $type): void {
|
->where(function (Builder $stateQuery) use ($policy, $type): void {
|
||||||
$stateQuery
|
$stateQuery
|
||||||
->where(function (Builder $queuedQuery) use ($policy, $type): void {
|
->where(function (Builder $queuedQuery) use ($policy, $type): void {
|
||||||
@ -343,7 +349,7 @@ public static function latestCompletedCoverageBearingInventorySyncForTenant(int
|
|||||||
|
|
||||||
return static::query()
|
return static::query()
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->where('type', 'inventory_sync')
|
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::InventorySync->value))
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
->whereNotNull('completed_at')
|
->whereNotNull('completed_at')
|
||||||
->latest('completed_at')
|
->latest('completed_at')
|
||||||
@ -478,11 +484,11 @@ public function baselineGapEnvelope(): array
|
|||||||
{
|
{
|
||||||
$context = is_array($this->context) ? $this->context : [];
|
$context = is_array($this->context) ? $this->context : [];
|
||||||
|
|
||||||
return match ((string) $this->type) {
|
return match ($this->canonicalOperationType()) {
|
||||||
'baseline_compare' => is_array(data_get($context, 'baseline_compare.evidence_gaps'))
|
'baseline.compare' => is_array(data_get($context, 'baseline_compare.evidence_gaps'))
|
||||||
? data_get($context, 'baseline_compare.evidence_gaps')
|
? data_get($context, 'baseline_compare.evidence_gaps')
|
||||||
: [],
|
: [],
|
||||||
'baseline_capture' => is_array(data_get($context, 'baseline_capture.gaps'))
|
'baseline.capture' => is_array(data_get($context, 'baseline_capture.gaps'))
|
||||||
? data_get($context, 'baseline_capture.gaps')
|
? data_get($context, 'baseline_capture.gaps')
|
||||||
: [],
|
: [],
|
||||||
default => [],
|
default => [],
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
use App\Services\Providers\ProviderOperationStartGate;
|
use App\Services\Providers\ProviderOperationStartGate;
|
||||||
use App\Services\Providers\ProviderOperationStartResult;
|
use App\Services\Providers\ProviderOperationStartResult;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
|
|
||||||
class EntraGroupSyncService
|
class EntraGroupSyncService
|
||||||
@ -32,7 +33,7 @@ public function startManualSync(Tenant $tenant, User $user): ProviderOperationSt
|
|||||||
return $this->providerStarts->start(
|
return $this->providerStarts->start(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
connection: null,
|
connection: null,
|
||||||
operationType: 'entra_group_sync',
|
operationType: OperationRunType::DirectoryGroupsSync->value,
|
||||||
dispatcher: function (OperationRun $run) use ($tenant, $selectionKey): void {
|
dispatcher: function (OperationRun $run) use ($tenant, $selectionKey): void {
|
||||||
$providerConnectionId = is_numeric($run->context['provider_connection_id'] ?? null)
|
$providerConnectionId = is_numeric($run->context['provider_connection_id'] ?? null)
|
||||||
? (int) $run->context['provider_connection_id']
|
? (int) $run->context['provider_connection_id']
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
use App\Services\Providers\ProviderOperationStartGate;
|
use App\Services\Providers\ProviderOperationStartGate;
|
||||||
use App\Services\Providers\ProviderOperationStartResult;
|
use App\Services\Providers\ProviderOperationStartResult;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
|
|
||||||
class RoleDefinitionsSyncService
|
class RoleDefinitionsSyncService
|
||||||
@ -32,7 +33,7 @@ public function startManualSync(Tenant $tenant, User $user): ProviderOperationSt
|
|||||||
return $this->providerStarts->start(
|
return $this->providerStarts->start(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
connection: null,
|
connection: null,
|
||||||
operationType: 'directory_role_definitions.sync',
|
operationType: OperationRunType::DirectoryRoleDefinitionsSync->value,
|
||||||
dispatcher: function (OperationRun $run) use ($tenant): void {
|
dispatcher: function (OperationRun $run) use ($tenant): void {
|
||||||
$providerConnectionId = is_numeric($run->context['provider_connection_id'] ?? null)
|
$providerConnectionId = is_numeric($run->context['provider_connection_id'] ?? null)
|
||||||
? (int) $run->context['provider_connection_id']
|
? (int) $run->context['provider_connection_id']
|
||||||
|
|||||||
@ -44,6 +44,7 @@ public function collect(Tenant $tenant): array
|
|||||||
'entries' => $runs->map(static fn (OperationRun $run): array => [
|
'entries' => $runs->map(static fn (OperationRun $run): array => [
|
||||||
'id' => (int) $run->getKey(),
|
'id' => (int) $run->getKey(),
|
||||||
'type' => (string) $run->type,
|
'type' => (string) $run->type,
|
||||||
|
'operation_type' => $run->canonicalOperationType(),
|
||||||
'status' => (string) $run->status,
|
'status' => (string) $run->status,
|
||||||
'outcome' => (string) $run->outcome,
|
'outcome' => (string) $run->outcome,
|
||||||
'initiator_name' => $run->user?->name,
|
'initiator_name' => $run->user?->name,
|
||||||
|
|||||||
@ -6,6 +6,8 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\BackupScheduling\PolicyTypeResolver;
|
use App\Services\BackupScheduling\PolicyTypeResolver;
|
||||||
|
use App\Support\OperationCatalog;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
class InventoryMissingService
|
class InventoryMissingService
|
||||||
@ -27,7 +29,7 @@ public function missingForSelection(Tenant $tenant, array $selectionPayload): ar
|
|||||||
|
|
||||||
$latestRun = OperationRun::query()
|
$latestRun = OperationRun::query()
|
||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
->where('type', 'inventory_sync')
|
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::InventorySync->value))
|
||||||
->where('status', 'completed')
|
->where('status', 'completed')
|
||||||
->where('context->selection_hash', $selectionHash)
|
->where('context->selection_hash', $selectionHash)
|
||||||
->orderByDesc('completed_at')
|
->orderByDesc('completed_at')
|
||||||
|
|||||||
@ -59,7 +59,7 @@ public function syncNow(Tenant $tenant, array $selectionPayload): OperationRun
|
|||||||
'type' => OperationRunType::InventorySync->value,
|
'type' => OperationRunType::InventorySync->value,
|
||||||
'status' => OperationRunStatus::Running->value,
|
'status' => OperationRunStatus::Running->value,
|
||||||
'outcome' => OperationRunOutcome::Pending->value,
|
'outcome' => OperationRunOutcome::Pending->value,
|
||||||
'run_identity_hash' => hash('sha256', (string) $tenant->getKey().':inventory_sync:'.$selectionHash.':'.Str::uuid()->toString()),
|
'run_identity_hash' => hash('sha256', (string) $tenant->getKey().':'.OperationRunType::InventorySync->value.':'.$selectionHash.':'.Str::uuid()->toString()),
|
||||||
'context' => array_merge($normalizedSelection, [
|
'context' => array_merge($normalizedSelection, [
|
||||||
'selection_hash' => $selectionHash,
|
'selection_hash' => $selectionHash,
|
||||||
]),
|
]),
|
||||||
@ -698,7 +698,7 @@ private function resolveFoundationPolicyAnchor(
|
|||||||
|
|
||||||
private function selectionLockKey(Tenant $tenant, string $selectionHash): string
|
private function selectionLockKey(Tenant $tenant, string $selectionHash): string
|
||||||
{
|
{
|
||||||
return sprintf('inventory_sync:tenant:%s:selection:%s', (string) $tenant->getKey(), $selectionHash);
|
return sprintf('%s:tenant:%s:selection:%s', OperationRunType::InventorySync->value, (string) $tenant->getKey(), $selectionHash);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function mapGraphFailureToErrorCode(GraphResponse $response): string
|
private function mapGraphFailureToErrorCode(GraphResponse $response): string
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
use App\Services\Tenants\TenantOperabilityService;
|
use App\Services\Tenants\TenantOperabilityService;
|
||||||
use App\Support\Onboarding\OnboardingCheckpoint;
|
use App\Support\Onboarding\OnboardingCheckpoint;
|
||||||
use App\Support\Onboarding\OnboardingLifecycleState;
|
use App\Support\Onboarding\OnboardingLifecycleState;
|
||||||
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\Verification\VerificationReportOverall;
|
use App\Support\Verification\VerificationReportOverall;
|
||||||
@ -682,7 +683,7 @@ private function bootstrapOperationTypes(TenantOnboardingSession $draft): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
return array_values(array_filter(
|
return array_values(array_filter(
|
||||||
array_map(static fn (mixed $value): string => is_string($value) ? trim($value) : '', $types),
|
array_map(static fn (mixed $value): string => is_string($value) ? OperationCatalog::canonicalCode($value) : '', $types),
|
||||||
static fn (string $value): bool => $value !== '',
|
static fn (string $value): bool => $value !== '',
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@ -709,7 +710,7 @@ private function bootstrapRunMap(TenantOnboardingSession $draft, array $selected
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$runMap[trim($type)] = $normalizedRunId;
|
$runMap[OperationCatalog::canonicalCode($type)] = $normalizedRunId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1271,7 +1271,7 @@ private function writeTerminalAudit(OperationRun $run): void
|
|||||||
action: $action,
|
action: $action,
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
'operation_type' => $run->type,
|
'operation_type' => $run->canonicalOperationType(),
|
||||||
'summary_counts' => $this->sanitizeSummaryCounts(is_array($run->summary_counts ?? null) ? $run->summary_counts : []),
|
'summary_counts' => $this->sanitizeSummaryCounts(is_array($run->summary_counts ?? null) ? $run->summary_counts : []),
|
||||||
'failure_summary' => $run->failure_summary,
|
'failure_summary' => $run->failure_summary,
|
||||||
'target_scope' => $executionLegitimacy['target_scope'] ?? ($context['target_scope'] ?? null),
|
'target_scope' => $executionLegitimacy['target_scope'] ?? ($context['target_scope'] ?? null),
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Services\Tenants\TenantOperabilityService;
|
use App\Services\Tenants\TenantOperabilityService;
|
||||||
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\Operations\ExecutionAuthorityMode;
|
use App\Support\Operations\ExecutionAuthorityMode;
|
||||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
use App\Support\Operations\ExecutionDenialReasonCode;
|
||||||
use App\Support\Operations\OperationRunCapabilityResolver;
|
use App\Support\Operations\OperationRunCapabilityResolver;
|
||||||
@ -27,9 +28,9 @@ class QueuedExecutionLegitimacyGate
|
|||||||
* @var list<string>
|
* @var list<string>
|
||||||
*/
|
*/
|
||||||
private const SYSTEM_AUTHORITY_ALLOWLIST = [
|
private const SYSTEM_AUTHORITY_ALLOWLIST = [
|
||||||
'backup_schedule_run',
|
'backup.schedule.execute',
|
||||||
'backup_schedule_retention',
|
'backup.schedule.retention',
|
||||||
'backup_schedule_purge',
|
'backup.schedule.purge',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@ -134,6 +135,7 @@ public function evaluate(OperationRun $run): QueuedExecutionLegitimacyDecision
|
|||||||
public function buildContext(OperationRun $run): QueuedExecutionContext
|
public function buildContext(OperationRun $run): QueuedExecutionContext
|
||||||
{
|
{
|
||||||
$context = is_array($run->context) ? $run->context : [];
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
|
$operationType = OperationCatalog::canonicalCode((string) $run->type);
|
||||||
$authorityMode = ExecutionAuthorityMode::fromNullable($context['execution_authority_mode'] ?? null)
|
$authorityMode = ExecutionAuthorityMode::fromNullable($context['execution_authority_mode'] ?? null)
|
||||||
?? ($run->user_id === null ? ExecutionAuthorityMode::SystemAuthority : ExecutionAuthorityMode::ActorBound);
|
?? ($run->user_id === null ? ExecutionAuthorityMode::SystemAuthority : ExecutionAuthorityMode::ActorBound);
|
||||||
$providerConnectionId = $this->resolveProviderConnectionId($context);
|
$providerConnectionId = $this->resolveProviderConnectionId($context);
|
||||||
@ -141,26 +143,28 @@ public function buildContext(OperationRun $run): QueuedExecutionContext
|
|||||||
|
|
||||||
return new QueuedExecutionContext(
|
return new QueuedExecutionContext(
|
||||||
run: $run,
|
run: $run,
|
||||||
operationType: (string) $run->type,
|
operationType: $operationType,
|
||||||
workspaceId: $workspaceId,
|
workspaceId: $workspaceId,
|
||||||
tenant: $run->tenant,
|
tenant: $run->tenant,
|
||||||
initiator: $run->user,
|
initiator: $run->user,
|
||||||
authorityMode: $authorityMode,
|
authorityMode: $authorityMode,
|
||||||
requiredCapability: is_string($context['required_capability'] ?? null)
|
requiredCapability: is_string($context['required_capability'] ?? null)
|
||||||
? $context['required_capability']
|
? $context['required_capability']
|
||||||
: $this->operationRunCapabilityResolver->requiredExecutionCapabilityForType((string) $run->type),
|
: $this->operationRunCapabilityResolver->requiredExecutionCapabilityForType($operationType),
|
||||||
providerConnectionId: $providerConnectionId,
|
providerConnectionId: $providerConnectionId,
|
||||||
targetScope: [
|
targetScope: [
|
||||||
'workspace_id' => $workspaceId,
|
'workspace_id' => $workspaceId,
|
||||||
'tenant_id' => $run->tenant_id !== null ? (int) $run->tenant_id : null,
|
'tenant_id' => $run->tenant_id !== null ? (int) $run->tenant_id : null,
|
||||||
'provider_connection_id' => $providerConnectionId,
|
'provider_connection_id' => $providerConnectionId,
|
||||||
],
|
],
|
||||||
prerequisiteClasses: $this->prerequisiteClassesFor((string) $run->type, $providerConnectionId),
|
prerequisiteClasses: $this->prerequisiteClassesFor($operationType, $providerConnectionId),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isSystemAuthorityAllowed(string $operationType): bool
|
public function isSystemAuthorityAllowed(string $operationType): bool
|
||||||
{
|
{
|
||||||
|
$operationType = OperationCatalog::canonicalCode($operationType);
|
||||||
|
|
||||||
return in_array($operationType, self::SYSTEM_AUTHORITY_ALLOWLIST, true);
|
return in_array($operationType, self::SYSTEM_AUTHORITY_ALLOWLIST, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -23,8 +23,8 @@ public function definitions(): array
|
|||||||
'label' => 'Provider connection check',
|
'label' => 'Provider connection check',
|
||||||
'required_capability' => Capabilities::PROVIDER_RUN,
|
'required_capability' => Capabilities::PROVIDER_RUN,
|
||||||
],
|
],
|
||||||
'inventory_sync' => [
|
'inventory.sync' => [
|
||||||
'operation_type' => 'inventory_sync',
|
'operation_type' => 'inventory.sync',
|
||||||
'module' => 'inventory',
|
'module' => 'inventory',
|
||||||
'label' => 'Inventory sync',
|
'label' => 'Inventory sync',
|
||||||
'required_capability' => Capabilities::PROVIDER_RUN,
|
'required_capability' => Capabilities::PROVIDER_RUN,
|
||||||
@ -41,14 +41,14 @@ public function definitions(): array
|
|||||||
'label' => 'Restore execution',
|
'label' => 'Restore execution',
|
||||||
'required_capability' => Capabilities::TENANT_MANAGE,
|
'required_capability' => Capabilities::TENANT_MANAGE,
|
||||||
],
|
],
|
||||||
'entra_group_sync' => [
|
'directory.groups.sync' => [
|
||||||
'operation_type' => 'entra_group_sync',
|
'operation_type' => 'directory.groups.sync',
|
||||||
'module' => 'directory_groups',
|
'module' => 'directory_groups',
|
||||||
'label' => 'Directory groups sync',
|
'label' => 'Directory groups sync',
|
||||||
'required_capability' => Capabilities::TENANT_SYNC,
|
'required_capability' => Capabilities::TENANT_SYNC,
|
||||||
],
|
],
|
||||||
'directory_role_definitions.sync' => [
|
'directory.role_definitions.sync' => [
|
||||||
'operation_type' => 'directory_role_definitions.sync',
|
'operation_type' => 'directory.role_definitions.sync',
|
||||||
'module' => 'directory_role_definitions',
|
'module' => 'directory_role_definitions',
|
||||||
'label' => 'Role definitions sync',
|
'label' => 'Role definitions sync',
|
||||||
'required_capability' => Capabilities::TENANT_MANAGE,
|
'required_capability' => Capabilities::TENANT_MANAGE,
|
||||||
@ -77,9 +77,9 @@ public function providerBindings(): array
|
|||||||
exceptionNotes: 'Current-release provider binding remains Microsoft-only until a real second provider case exists.',
|
exceptionNotes: 'Current-release provider binding remains Microsoft-only until a real second provider case exists.',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
'inventory_sync' => [
|
'inventory.sync' => [
|
||||||
'microsoft' => $this->activeMicrosoftBinding(
|
'microsoft' => $this->activeMicrosoftBinding(
|
||||||
operationType: 'inventory_sync',
|
operationType: 'inventory.sync',
|
||||||
handlerNotes: 'Uses the current Microsoft Intune inventory sync workflow.',
|
handlerNotes: 'Uses the current Microsoft Intune inventory sync workflow.',
|
||||||
exceptionNotes: 'Inventory collection is currently Microsoft Intune-specific provider behavior.',
|
exceptionNotes: 'Inventory collection is currently Microsoft Intune-specific provider behavior.',
|
||||||
),
|
),
|
||||||
@ -98,16 +98,16 @@ public function providerBindings(): array
|
|||||||
exceptionNotes: 'Restore execution remains Microsoft-only and must preserve dry-run and audit safeguards.',
|
exceptionNotes: 'Restore execution remains Microsoft-only and must preserve dry-run and audit safeguards.',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
'entra_group_sync' => [
|
'directory.groups.sync' => [
|
||||||
'microsoft' => $this->activeMicrosoftBinding(
|
'microsoft' => $this->activeMicrosoftBinding(
|
||||||
operationType: 'entra_group_sync',
|
operationType: 'directory.groups.sync',
|
||||||
handlerNotes: 'Uses the current Microsoft Entra group synchronization workflow.',
|
handlerNotes: 'Uses the current Microsoft Entra group synchronization workflow.',
|
||||||
exceptionNotes: 'The operation type keeps current Entra vocabulary until the identity-neutrality follow-up.',
|
exceptionNotes: 'The operation type keeps current Entra vocabulary until the identity-neutrality follow-up.',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
'directory_role_definitions.sync' => [
|
'directory.role_definitions.sync' => [
|
||||||
'microsoft' => $this->activeMicrosoftBinding(
|
'microsoft' => $this->activeMicrosoftBinding(
|
||||||
operationType: 'directory_role_definitions.sync',
|
operationType: 'directory.role_definitions.sync',
|
||||||
handlerNotes: 'Uses the current Microsoft directory role definition synchronization workflow.',
|
handlerNotes: 'Uses the current Microsoft directory role definition synchronization workflow.',
|
||||||
exceptionNotes: 'Directory role definitions are Microsoft-owned provider semantics.',
|
exceptionNotes: 'Directory role definitions are Microsoft-owned provider semantics.',
|
||||||
),
|
),
|
||||||
|
|||||||
@ -597,7 +597,7 @@ private function dispatchFailureAlertSafely(OperationRun $run): void
|
|||||||
'body' => 'A findings lifecycle backfill run failed.',
|
'body' => 'A findings lifecycle backfill run failed.',
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
'operation_run_id' => (int) $run->getKey(),
|
'operation_run_id' => (int) $run->getKey(),
|
||||||
'operation_type' => (string) $run->type,
|
'operation_type' => $run->canonicalOperationType(),
|
||||||
'scope' => (string) data_get($run->context, 'runbook.scope', ''),
|
'scope' => (string) data_get($run->context, 'runbook.scope', ''),
|
||||||
'view_run_url' => SystemOperationRunLinks::view($run),
|
'view_run_url' => SystemOperationRunLinks::view($run),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -14,10 +14,9 @@
|
|||||||
final class OperationRunTriageService
|
final class OperationRunTriageService
|
||||||
{
|
{
|
||||||
private const RETRYABLE_TYPES = [
|
private const RETRYABLE_TYPES = [
|
||||||
'inventory_sync',
|
'inventory.sync',
|
||||||
'policy.sync',
|
'policy.sync',
|
||||||
'policy.sync_one',
|
'directory.groups.sync',
|
||||||
'entra_group_sync',
|
|
||||||
'findings.lifecycle.backfill',
|
'findings.lifecycle.backfill',
|
||||||
'rbac.health_check',
|
'rbac.health_check',
|
||||||
'entra.admin_roles.scan',
|
'entra.admin_roles.scan',
|
||||||
@ -26,10 +25,9 @@ final class OperationRunTriageService
|
|||||||
];
|
];
|
||||||
|
|
||||||
private const CANCELABLE_TYPES = [
|
private const CANCELABLE_TYPES = [
|
||||||
'inventory_sync',
|
'inventory.sync',
|
||||||
'policy.sync',
|
'policy.sync',
|
||||||
'policy.sync_one',
|
'directory.groups.sync',
|
||||||
'entra_group_sync',
|
|
||||||
'findings.lifecycle.backfill',
|
'findings.lifecycle.backfill',
|
||||||
'rbac.health_check',
|
'rbac.health_check',
|
||||||
'entra.admin_roles.scan',
|
'entra.admin_roles.scan',
|
||||||
@ -46,7 +44,7 @@ public function canRetry(OperationRun $run): bool
|
|||||||
{
|
{
|
||||||
return (string) $run->status === OperationRunStatus::Completed->value
|
return (string) $run->status === OperationRunStatus::Completed->value
|
||||||
&& (string) $run->outcome === OperationRunOutcome::Failed->value
|
&& (string) $run->outcome === OperationRunOutcome::Failed->value
|
||||||
&& in_array((string) $run->type, self::RETRYABLE_TYPES, true);
|
&& in_array($run->canonicalOperationType(), self::RETRYABLE_TYPES, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function canCancel(OperationRun $run): bool
|
public function canCancel(OperationRun $run): bool
|
||||||
@ -55,7 +53,7 @@ public function canCancel(OperationRun $run): bool
|
|||||||
OperationRunStatus::Queued->value,
|
OperationRunStatus::Queued->value,
|
||||||
OperationRunStatus::Running->value,
|
OperationRunStatus::Running->value,
|
||||||
], true)
|
], true)
|
||||||
&& in_array((string) $run->type, self::CANCELABLE_TYPES, true);
|
&& in_array($run->canonicalOperationType(), self::CANCELABLE_TYPES, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function retry(OperationRun $run, PlatformUser $actor): OperationRun
|
public function retry(OperationRun $run, PlatformUser $actor): OperationRun
|
||||||
@ -83,7 +81,7 @@ public function retry(OperationRun $run, PlatformUser $actor): OperationRun
|
|||||||
'tenant_id' => $run->tenant_id !== null ? (int) $run->tenant_id : null,
|
'tenant_id' => $run->tenant_id !== null ? (int) $run->tenant_id : null,
|
||||||
'user_id' => null,
|
'user_id' => null,
|
||||||
'initiator_name' => $actor->name ?? 'Platform operator',
|
'initiator_name' => $actor->name ?? 'Platform operator',
|
||||||
'type' => (string) $run->type,
|
'type' => $run->canonicalOperationType(),
|
||||||
'status' => OperationRunStatus::Queued->value,
|
'status' => OperationRunStatus::Queued->value,
|
||||||
'outcome' => OperationRunOutcome::Pending->value,
|
'outcome' => OperationRunOutcome::Pending->value,
|
||||||
'run_identity_hash' => hash('sha256', 'retry|'.$run->getKey().'|'.now()->format('U.u').'|'.bin2hex(random_bytes(8))),
|
'run_identity_hash' => hash('sha256', 'retry|'.$run->getKey().'|'.now()->format('U.u').'|'.bin2hex(random_bytes(8))),
|
||||||
@ -100,7 +98,7 @@ public function retry(OperationRun $run, PlatformUser $actor): OperationRun
|
|||||||
metadata: [
|
metadata: [
|
||||||
'source_run_id' => (int) $run->getKey(),
|
'source_run_id' => (int) $run->getKey(),
|
||||||
'new_run_id' => (int) $retryRun->getKey(),
|
'new_run_id' => (int) $retryRun->getKey(),
|
||||||
'operation_type' => (string) $run->type,
|
'operation_type' => $run->canonicalOperationType(),
|
||||||
],
|
],
|
||||||
run: $retryRun,
|
run: $retryRun,
|
||||||
);
|
);
|
||||||
@ -152,7 +150,7 @@ public function cancel(OperationRun $run, PlatformUser $actor, string $reason):
|
|||||||
actor: $actor,
|
actor: $actor,
|
||||||
action: 'platform.system_console.cancel',
|
action: 'platform.system_console.cancel',
|
||||||
metadata: [
|
metadata: [
|
||||||
'operation_type' => (string) $run->type,
|
'operation_type' => $run->canonicalOperationType(),
|
||||||
'reason' => $reason,
|
'reason' => $reason,
|
||||||
],
|
],
|
||||||
run: $cancelledRun,
|
run: $cancelledRun,
|
||||||
@ -192,7 +190,7 @@ public function markInvestigated(OperationRun $run, PlatformUser $actor, string
|
|||||||
action: 'platform.system_console.mark_investigated',
|
action: 'platform.system_console.mark_investigated',
|
||||||
metadata: [
|
metadata: [
|
||||||
'reason' => $reason,
|
'reason' => $reason,
|
||||||
'operation_type' => (string) $run->type,
|
'operation_type' => $run->canonicalOperationType(),
|
||||||
],
|
],
|
||||||
run: $run,
|
run: $run,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
||||||
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
@ -166,7 +167,7 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
|
|
||||||
$latestRun = OperationRun::query()
|
$latestRun = OperationRun::query()
|
||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
->where('type', 'baseline_compare')
|
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BaselineCompare->value))
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
@ -457,7 +458,7 @@ public static function forWidget(?Tenant $tenant): self
|
|||||||
|
|
||||||
$latestRun = OperationRun::query()
|
$latestRun = OperationRun::query()
|
||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
->where('type', 'baseline_compare')
|
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BaselineCompare->value))
|
||||||
->where('context->baseline_profile_id', (string) $profile->getKey())
|
->where('context->baseline_profile_id', (string) $profile->getKey())
|
||||||
->whereNotNull('completed_at')
|
->whereNotNull('completed_at')
|
||||||
->latest('completed_at')
|
->latest('completed_at')
|
||||||
|
|||||||
@ -121,17 +121,29 @@ public static function boundaryClassification(?PlatformVocabularyGlossary $gloss
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Read-side compatibility helper for historical rows only.
|
||||||
|
*
|
||||||
|
* Writers must emit the canonical code directly instead of translating a
|
||||||
|
* legacy alias through this method.
|
||||||
|
*
|
||||||
* @return list<string>
|
* @return list<string>
|
||||||
*/
|
*/
|
||||||
public static function rawValuesForCanonical(string $canonicalCode): array
|
public static function rawValuesForCanonical(string $canonicalCode): array
|
||||||
{
|
{
|
||||||
return array_values(array_map(
|
$canonicalCode = trim($canonicalCode);
|
||||||
|
$values = array_values(array_map(
|
||||||
static fn (OperationTypeAlias $alias): string => $alias->rawValue,
|
static fn (OperationTypeAlias $alias): string => $alias->rawValue,
|
||||||
array_filter(
|
array_filter(
|
||||||
self::operationAliases(),
|
self::operationAliases(),
|
||||||
static fn (OperationTypeAlias $alias): bool => $alias->canonicalCode === trim($canonicalCode),
|
static fn (OperationTypeAlias $alias): bool => $alias->canonicalCode === $canonicalCode,
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
|
|
||||||
|
if (array_key_exists($canonicalCode, self::canonicalDefinitions()) && ! in_array($canonicalCode, $values, true)) {
|
||||||
|
$values[] = $canonicalCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $values;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -191,6 +203,21 @@ public static function resolve(string $operationType): OperationTypeResolution
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$definition = self::canonicalDefinitions()[$operationType] ?? null;
|
||||||
|
|
||||||
|
if ($definition instanceof CanonicalOperationType) {
|
||||||
|
return new OperationTypeResolution(
|
||||||
|
rawValue: $operationType,
|
||||||
|
canonical: $definition,
|
||||||
|
aliasesConsidered: array_values(array_filter(
|
||||||
|
$aliases,
|
||||||
|
static fn (OperationTypeAlias $alias): bool => $alias->canonicalCode === $operationType,
|
||||||
|
)),
|
||||||
|
aliasStatus: 'canonical',
|
||||||
|
wasLegacyAlias: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return new OperationTypeResolution(
|
return new OperationTypeResolution(
|
||||||
rawValue: $operationType,
|
rawValue: $operationType,
|
||||||
canonical: new CanonicalOperationType(
|
canonical: new CanonicalOperationType(
|
||||||
@ -262,29 +289,29 @@ private static function operationAliases(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
new OperationTypeAlias('policy.sync', 'policy.sync', 'canonical', true),
|
new OperationTypeAlias('policy.sync', 'policy.sync', 'canonical', true),
|
||||||
new OperationTypeAlias('policy.sync_one', 'policy.sync', 'legacy_alias', true, 'Legacy single-policy sync values resolve to the canonical policy.sync operation.', 'Prefer policy.sync on platform-owned read paths.'),
|
new OperationTypeAlias('policy.sync_one', 'policy.sync', 'legacy_alias', false, 'Legacy single-policy sync values resolve to the canonical policy.sync operation.', 'Prefer policy.sync on platform-owned read paths.'),
|
||||||
new OperationTypeAlias('policy.capture_snapshot', 'policy.snapshot', 'canonical', true),
|
new OperationTypeAlias('policy.capture_snapshot', 'policy.snapshot', 'canonical', true),
|
||||||
new OperationTypeAlias('policy.delete', 'policy.delete', 'canonical', true),
|
new OperationTypeAlias('policy.delete', 'policy.delete', 'canonical', true),
|
||||||
new OperationTypeAlias('policy.unignore', 'policy.restore', 'legacy_alias', true, 'Legacy policy.unignore values resolve to policy.restore for operator-facing wording.', 'Prefer policy.restore on new platform-owned read models.'),
|
new OperationTypeAlias('policy.unignore', 'policy.restore', 'legacy_alias', false, 'Legacy policy.unignore values resolve to policy.restore for operator-facing wording.', 'Prefer policy.restore on new platform-owned read models.'),
|
||||||
new OperationTypeAlias('policy.export', 'policy.export', 'canonical', true),
|
new OperationTypeAlias('policy.export', 'policy.export', 'canonical', true),
|
||||||
new OperationTypeAlias('provider.connection.check', 'provider.connection.check', 'canonical', true),
|
new OperationTypeAlias('provider.connection.check', 'provider.connection.check', 'canonical', true),
|
||||||
new OperationTypeAlias('inventory_sync', 'inventory.sync', 'legacy_alias', true, 'Legacy inventory_sync storage values resolve to the canonical inventory.sync operation.', 'Preserve stored values during rollout while showing inventory.sync semantics on read paths.'),
|
new OperationTypeAlias('inventory_sync', 'inventory.sync', 'legacy_alias', false, 'Legacy inventory_sync storage values resolve to the canonical inventory.sync operation.', 'Preserve stored values during rollout while showing inventory.sync semantics on read paths.'),
|
||||||
new OperationTypeAlias('provider.inventory.sync', 'inventory.sync', 'legacy_alias', false, 'Provider-prefixed historical inventory sync values share the same operator meaning as inventory sync.', 'Avoid emitting provider.inventory.sync on new platform-owned surfaces.'),
|
new OperationTypeAlias('provider.inventory.sync', 'inventory.sync', 'legacy_alias', false, 'Provider-prefixed historical inventory sync values share the same operator meaning as inventory sync.', 'Avoid emitting provider.inventory.sync on new platform-owned surfaces.'),
|
||||||
new OperationTypeAlias('compliance.snapshot', 'compliance.snapshot', 'canonical', true),
|
new OperationTypeAlias('compliance.snapshot', 'compliance.snapshot', 'canonical', true),
|
||||||
new OperationTypeAlias('provider.compliance.snapshot', 'compliance.snapshot', 'legacy_alias', false, 'Provider-prefixed compliance snapshot values resolve to the canonical compliance.snapshot operation.', 'Avoid emitting provider.compliance.snapshot on new platform-owned surfaces.'),
|
new OperationTypeAlias('provider.compliance.snapshot', 'compliance.snapshot', 'legacy_alias', false, 'Provider-prefixed compliance snapshot values resolve to the canonical compliance.snapshot operation.', 'Avoid emitting provider.compliance.snapshot on new platform-owned surfaces.'),
|
||||||
new OperationTypeAlias('entra_group_sync', 'directory.groups.sync', 'legacy_alias', true, 'Historical entra_group_sync values resolve to directory.groups.sync.', 'Prefer directory.groups.sync on new platform-owned read models.'),
|
new OperationTypeAlias('entra_group_sync', 'directory.groups.sync', 'legacy_alias', false, 'Historical entra_group_sync values resolve to directory.groups.sync.', 'Prefer directory.groups.sync on new platform-owned read models.'),
|
||||||
new OperationTypeAlias('backup_set.update', 'backup_set.update', 'canonical', true),
|
new OperationTypeAlias('backup_set.update', 'backup_set.update', 'canonical', true),
|
||||||
new OperationTypeAlias('backup_set.delete', 'backup_set.archive', 'canonical', true),
|
new OperationTypeAlias('backup_set.delete', 'backup_set.archive', 'canonical', true),
|
||||||
new OperationTypeAlias('backup_set.restore', 'backup_set.restore', 'canonical', true),
|
new OperationTypeAlias('backup_set.restore', 'backup_set.restore', 'canonical', true),
|
||||||
new OperationTypeAlias('backup_set.force_delete', 'backup_set.delete', 'legacy_alias', true, 'Force-delete wording is normalized to the canonical delete label.', 'Use backup_set.delete for new platform-owned summaries.'),
|
new OperationTypeAlias('backup_set.force_delete', 'backup_set.delete', 'legacy_alias', false, 'Force-delete wording is normalized to the canonical delete label.', 'Use backup_set.delete for new platform-owned summaries.'),
|
||||||
new OperationTypeAlias('backup_schedule_run', 'backup.schedule.execute', 'legacy_alias', true, 'Historical backup_schedule_run values resolve to backup.schedule.execute.', 'Prefer backup.schedule.execute on canonical read paths.'),
|
new OperationTypeAlias('backup_schedule_run', 'backup.schedule.execute', 'legacy_alias', false, 'Historical backup_schedule_run values resolve to backup.schedule.execute.', 'Prefer backup.schedule.execute on canonical read paths.'),
|
||||||
new OperationTypeAlias('backup_schedule_retention', 'backup.schedule.retention', 'legacy_alias', true, 'Legacy backup schedule retention values resolve to backup.schedule.retention.', 'Prefer dotted canonical backup schedule naming on new read paths.'),
|
new OperationTypeAlias('backup_schedule_retention', 'backup.schedule.retention', 'legacy_alias', false, 'Legacy backup schedule retention values resolve to backup.schedule.retention.', 'Prefer dotted canonical backup schedule naming on new read paths.'),
|
||||||
new OperationTypeAlias('backup_schedule_purge', 'backup.schedule.purge', 'legacy_alias', true, 'Legacy backup schedule purge values resolve to backup.schedule.purge.', 'Prefer dotted canonical backup schedule naming on new read paths.'),
|
new OperationTypeAlias('backup_schedule_purge', 'backup.schedule.purge', 'legacy_alias', false, 'Legacy backup schedule purge values resolve to backup.schedule.purge.', 'Prefer dotted canonical backup schedule naming on new read paths.'),
|
||||||
new OperationTypeAlias('restore.execute', 'restore.execute', 'canonical', true),
|
new OperationTypeAlias('restore.execute', 'restore.execute', 'canonical', true),
|
||||||
new OperationTypeAlias('assignments.fetch', 'assignments.fetch', 'canonical', true),
|
new OperationTypeAlias('assignments.fetch', 'assignments.fetch', 'canonical', true),
|
||||||
new OperationTypeAlias('assignments.restore', 'assignments.restore', 'canonical', true),
|
new OperationTypeAlias('assignments.restore', 'assignments.restore', 'canonical', true),
|
||||||
new OperationTypeAlias('ops.reconcile_adapter_runs', 'ops.reconcile_adapter_runs', 'canonical', true),
|
new OperationTypeAlias('ops.reconcile_adapter_runs', 'ops.reconcile_adapter_runs', 'canonical', true),
|
||||||
new OperationTypeAlias('directory_role_definitions.sync', 'directory.role_definitions.sync', 'legacy_alias', true, 'Legacy directory_role_definitions.sync values resolve to directory.role_definitions.sync.', 'Prefer dotted role-definition naming on new read paths.'),
|
new OperationTypeAlias('directory_role_definitions.sync', 'directory.role_definitions.sync', 'legacy_alias', false, 'Legacy directory_role_definitions.sync values resolve to directory.role_definitions.sync.', 'Prefer dotted role-definition naming on new read paths.'),
|
||||||
new OperationTypeAlias('restore_run.delete', 'restore_run.delete', 'canonical', true),
|
new OperationTypeAlias('restore_run.delete', 'restore_run.delete', 'canonical', true),
|
||||||
new OperationTypeAlias('restore_run.restore', 'restore_run.restore', 'canonical', true),
|
new OperationTypeAlias('restore_run.restore', 'restore_run.restore', 'canonical', true),
|
||||||
new OperationTypeAlias('restore_run.force_delete', 'restore_run.force_delete', 'canonical', true),
|
new OperationTypeAlias('restore_run.force_delete', 'restore_run.force_delete', 'canonical', true),
|
||||||
@ -296,9 +323,9 @@ private static function operationAliases(): array
|
|||||||
new OperationTypeAlias('alerts.deliver', 'alerts.deliver', 'canonical', true),
|
new OperationTypeAlias('alerts.deliver', 'alerts.deliver', 'canonical', true),
|
||||||
new OperationTypeAlias('baseline.capture', 'baseline.capture', 'canonical', true),
|
new OperationTypeAlias('baseline.capture', 'baseline.capture', 'canonical', true),
|
||||||
new OperationTypeAlias('baseline.compare', 'baseline.compare', 'canonical', true),
|
new OperationTypeAlias('baseline.compare', 'baseline.compare', 'canonical', true),
|
||||||
new OperationTypeAlias('baseline_capture', 'baseline.capture', 'legacy_alias', true, 'Historical baseline_capture values resolve to baseline.capture.', 'Prefer baseline.capture on canonical read paths.'),
|
new OperationTypeAlias('baseline_capture', 'baseline.capture', 'legacy_alias', false, 'Historical baseline_capture values resolve to baseline.capture.', 'Prefer baseline.capture on canonical read paths.'),
|
||||||
new OperationTypeAlias('baseline_compare', 'baseline.compare', 'legacy_alias', true, 'Historical baseline_compare values resolve to baseline.compare.', 'Prefer baseline.compare on canonical read paths.'),
|
new OperationTypeAlias('baseline_compare', 'baseline.compare', 'legacy_alias', false, 'Historical baseline_compare values resolve to baseline.compare.', 'Prefer baseline.compare on canonical read paths.'),
|
||||||
new OperationTypeAlias('permission_posture_check', 'permission.posture.check', 'legacy_alias', true, 'Historical permission_posture_check values resolve to permission.posture.check.', 'Prefer dotted permission posture naming on new read paths.'),
|
new OperationTypeAlias('permission_posture_check', 'permission.posture.check', 'legacy_alias', false, 'Historical permission_posture_check values resolve to permission.posture.check.', 'Prefer dotted permission posture naming on new read paths.'),
|
||||||
new OperationTypeAlias('entra.admin_roles.scan', 'entra.admin_roles.scan', 'canonical', true),
|
new OperationTypeAlias('entra.admin_roles.scan', 'entra.admin_roles.scan', 'canonical', true),
|
||||||
new OperationTypeAlias('tenant.review_pack.generate', 'tenant.review_pack.generate', 'canonical', true),
|
new OperationTypeAlias('tenant.review_pack.generate', 'tenant.review_pack.generate', 'canonical', true),
|
||||||
new OperationTypeAlias('tenant.review.compose', 'tenant.review.compose', 'canonical', true),
|
new OperationTypeAlias('tenant.review.compose', 'tenant.review.compose', 'canonical', true),
|
||||||
|
|||||||
@ -108,7 +108,7 @@ public static function index(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (is_string($operationType) && $operationType !== '') {
|
if (is_string($operationType) && $operationType !== '') {
|
||||||
$parameters['tableFilters']['type']['value'] = $operationType;
|
$parameters['tableFilters']['type']['value'] = OperationCatalog::canonicalCode($operationType);
|
||||||
}
|
}
|
||||||
|
|
||||||
return route('admin.operations.index', $parameters);
|
return route('admin.operations.index', $parameters);
|
||||||
@ -145,17 +145,18 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
$providerConnectionId = $context['provider_connection_id'] ?? null;
|
$providerConnectionId = $context['provider_connection_id'] ?? null;
|
||||||
|
$canonicalType = $run->canonicalOperationType();
|
||||||
|
|
||||||
if (is_numeric($providerConnectionId) && class_exists(ProviderConnectionResource::class)) {
|
if (is_numeric($providerConnectionId) && class_exists(ProviderConnectionResource::class)) {
|
||||||
$links['Provider Connections'] = ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
|
$links['Provider Connections'] = ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
|
||||||
$links['Provider Connection'] = ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant, 'record' => (int) $providerConnectionId], panel: 'admin');
|
$links['Provider Connection'] = ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant, 'record' => (int) $providerConnectionId], panel: 'admin');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($run->type === 'inventory_sync') {
|
if ($canonicalType === 'inventory.sync') {
|
||||||
$links['Inventory'] = InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
$links['Inventory'] = InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (in_array($run->type, ['policy.sync', 'policy.sync_one'], true)) {
|
if ($canonicalType === 'policy.sync') {
|
||||||
$links['Policies'] = PolicyResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
$links['Policies'] = PolicyResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||||
|
|
||||||
$policyId = $context['policy_id'] ?? null;
|
$policyId = $context['policy_id'] ?? null;
|
||||||
@ -164,15 +165,15 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($run->type === 'entra_group_sync') {
|
if ($canonicalType === 'directory.groups.sync') {
|
||||||
$links['Directory Groups'] = EntraGroupResource::scopedUrl('index', tenant: $tenant);
|
$links['Directory Groups'] = EntraGroupResource::scopedUrl('index', tenant: $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($run->type === 'baseline_compare') {
|
if ($canonicalType === 'baseline.compare') {
|
||||||
$links['Drift'] = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
$links['Drift'] = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($run->type === 'baseline_capture') {
|
if ($canonicalType === 'baseline.capture') {
|
||||||
$snapshotId = data_get($context, 'result.snapshot_id');
|
$snapshotId = data_get($context, 'result.snapshot_id');
|
||||||
|
|
||||||
if (is_numeric($snapshotId)) {
|
if (is_numeric($snapshotId)) {
|
||||||
@ -180,7 +181,7 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($run->type === 'backup_set.update') {
|
if ($canonicalType === 'backup_set.update') {
|
||||||
$links['Backup Sets'] = BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
$links['Backup Sets'] = BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||||
|
|
||||||
$backupSetId = $context['backup_set_id'] ?? null;
|
$backupSetId = $context['backup_set_id'] ?? null;
|
||||||
@ -189,11 +190,11 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (in_array($run->type, ['backup_schedule_run', 'backup_schedule_retention', 'backup_schedule_purge'], true)) {
|
if (in_array($canonicalType, ['backup.schedule.execute', 'backup.schedule.retention', 'backup.schedule.purge'], true)) {
|
||||||
$links['Backup Schedules'] = BackupScheduleResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
$links['Backup Schedules'] = BackupScheduleResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($run->type === 'restore.execute') {
|
if ($canonicalType === 'restore.execute') {
|
||||||
$links['Restore Runs'] = RestoreRunResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
$links['Restore Runs'] = RestoreRunResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||||
|
|
||||||
$restoreRunId = $context['restore_run_id'] ?? null;
|
$restoreRunId = $context['restore_run_id'] ?? null;
|
||||||
@ -202,7 +203,7 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($run->type === 'tenant.evidence.snapshot.generate') {
|
if ($canonicalType === 'tenant.evidence.snapshot.generate') {
|
||||||
$snapshot = EvidenceSnapshot::query()
|
$snapshot = EvidenceSnapshot::query()
|
||||||
->where('operation_run_id', (int) $run->getKey())
|
->where('operation_run_id', (int) $run->getKey())
|
||||||
->latest('id')
|
->latest('id')
|
||||||
@ -213,7 +214,7 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($run->type === 'tenant.review.compose') {
|
if ($canonicalType === 'tenant.review.compose') {
|
||||||
$review = TenantReview::query()
|
$review = TenantReview::query()
|
||||||
->where('operation_run_id', (int) $run->getKey())
|
->where('operation_run_id', (int) $run->getKey())
|
||||||
->latest('id')
|
->latest('id')
|
||||||
@ -224,7 +225,7 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($run->type === 'tenant.review_pack.generate') {
|
if ($canonicalType === 'tenant.review_pack.generate') {
|
||||||
$pack = ReviewPack::query()
|
$pack = ReviewPack::query()
|
||||||
->where('operation_run_id', (int) $run->getKey())
|
->where('operation_run_id', (int) $run->getKey())
|
||||||
->latest('id')
|
->latest('id')
|
||||||
|
|||||||
@ -4,17 +4,16 @@
|
|||||||
|
|
||||||
enum OperationRunType: string
|
enum OperationRunType: string
|
||||||
{
|
{
|
||||||
case BaselineCapture = 'baseline_capture';
|
case BaselineCapture = 'baseline.capture';
|
||||||
case BaselineCompare = 'baseline_compare';
|
case BaselineCompare = 'baseline.compare';
|
||||||
case InventorySync = 'inventory_sync';
|
case InventorySync = 'inventory.sync';
|
||||||
case PolicySync = 'policy.sync';
|
case PolicySync = 'policy.sync';
|
||||||
case PolicySyncOne = 'policy.sync_one';
|
case DirectoryGroupsSync = 'directory.groups.sync';
|
||||||
case DirectoryGroupsSync = 'entra_group_sync';
|
|
||||||
case BackupSetUpdate = 'backup_set.update';
|
case BackupSetUpdate = 'backup_set.update';
|
||||||
case BackupScheduleExecute = 'backup_schedule_run';
|
case BackupScheduleExecute = 'backup.schedule.execute';
|
||||||
case BackupScheduleRetention = 'backup_schedule_retention';
|
case BackupScheduleRetention = 'backup.schedule.retention';
|
||||||
case BackupSchedulePurge = 'backup_schedule_purge';
|
case BackupSchedulePurge = 'backup.schedule.purge';
|
||||||
case DirectoryRoleDefinitionsSync = 'directory_role_definitions.sync';
|
case DirectoryRoleDefinitionsSync = 'directory.role_definitions.sync';
|
||||||
case RestoreExecute = 'restore.execute';
|
case RestoreExecute = 'restore.execute';
|
||||||
case EntraAdminRolesScan = 'entra.admin_roles.scan';
|
case EntraAdminRolesScan = 'entra.admin_roles.scan';
|
||||||
case ReviewPackGenerate = 'tenant.review_pack.generate';
|
case ReviewPackGenerate = 'tenant.review_pack.generate';
|
||||||
@ -29,24 +28,6 @@ public static function values(): array
|
|||||||
|
|
||||||
public function canonicalCode(): string
|
public function canonicalCode(): string
|
||||||
{
|
{
|
||||||
return match ($this) {
|
return $this->value;
|
||||||
self::BaselineCapture => 'baseline.capture',
|
|
||||||
self::BaselineCompare => 'baseline.compare',
|
|
||||||
self::InventorySync => 'inventory.sync',
|
|
||||||
self::PolicySync, self::PolicySyncOne => 'policy.sync',
|
|
||||||
self::DirectoryGroupsSync => 'directory.groups.sync',
|
|
||||||
self::BackupSetUpdate => 'backup_set.update',
|
|
||||||
self::BackupScheduleExecute => 'backup.schedule.execute',
|
|
||||||
self::BackupScheduleRetention => 'backup.schedule.retention',
|
|
||||||
self::BackupSchedulePurge => 'backup.schedule.purge',
|
|
||||||
self::DirectoryRoleDefinitionsSync => 'directory.role_definitions.sync',
|
|
||||||
self::RestoreExecute => 'restore.execute',
|
|
||||||
self::EntraAdminRolesScan => 'entra.admin_roles.scan',
|
|
||||||
self::ReviewPackGenerate => 'tenant.review_pack.generate',
|
|
||||||
self::TenantReviewCompose => 'tenant.review.compose',
|
|
||||||
self::EvidenceSnapshotGenerate => 'tenant.evidence.snapshot.generate',
|
|
||||||
self::RbacHealthCheck => 'rbac.health_check',
|
|
||||||
default => $this->value,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
namespace App\Support\Operations;
|
namespace App\Support\Operations;
|
||||||
|
|
||||||
|
use App\Support\OperationCatalog;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
final class OperationLifecyclePolicy
|
final class OperationLifecyclePolicy
|
||||||
@ -43,7 +44,8 @@ public function definition(string $operationType): ?array
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$definition = $this->coveredTypes()[$operationType] ?? null;
|
$canonicalType = OperationCatalog::canonicalCode($operationType);
|
||||||
|
$definition = $this->coveredTypes()[$canonicalType] ?? null;
|
||||||
|
|
||||||
if (! is_array($definition)) {
|
if (! is_array($definition)) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\OperationCatalog;
|
||||||
|
|
||||||
final class OperationRunCapabilityResolver
|
final class OperationRunCapabilityResolver
|
||||||
{
|
{
|
||||||
@ -14,18 +15,18 @@ public function requiredCapabilityForRun(OperationRun $run): ?string
|
|||||||
|
|
||||||
public function requiredCapabilityForType(string $operationType): ?string
|
public function requiredCapabilityForType(string $operationType): ?string
|
||||||
{
|
{
|
||||||
$operationType = trim($operationType);
|
$operationType = OperationCatalog::canonicalCode($operationType);
|
||||||
|
|
||||||
if ($operationType === '') {
|
if ($operationType === '') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return match ($operationType) {
|
return match ($operationType) {
|
||||||
'inventory_sync' => Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
'inventory.sync' => Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||||
'entra_group_sync' => Capabilities::TENANT_SYNC,
|
'directory.groups.sync' => Capabilities::TENANT_SYNC,
|
||||||
'backup_schedule_run', 'backup_schedule_retention', 'backup_schedule_purge' => Capabilities::TENANT_BACKUP_SCHEDULES_RUN,
|
'backup.schedule.execute', 'backup.schedule.retention', 'backup.schedule.purge' => Capabilities::TENANT_BACKUP_SCHEDULES_RUN,
|
||||||
'restore.execute' => Capabilities::TENANT_MANAGE,
|
'restore.execute' => Capabilities::TENANT_MANAGE,
|
||||||
'directory_role_definitions.sync' => Capabilities::TENANT_MANAGE,
|
'directory.role_definitions.sync' => Capabilities::TENANT_MANAGE,
|
||||||
'alerts.evaluate', 'alerts.deliver' => Capabilities::ALERTS_VIEW,
|
'alerts.evaluate', 'alerts.deliver' => Capabilities::ALERTS_VIEW,
|
||||||
'tenant.review.compose' => Capabilities::TENANT_REVIEW_VIEW,
|
'tenant.review.compose' => Capabilities::TENANT_REVIEW_VIEW,
|
||||||
|
|
||||||
@ -40,15 +41,15 @@ public function requiredCapabilityForType(string $operationType): ?string
|
|||||||
|
|
||||||
public function requiredExecutionCapabilityForType(string $operationType): ?string
|
public function requiredExecutionCapabilityForType(string $operationType): ?string
|
||||||
{
|
{
|
||||||
$operationType = trim($operationType);
|
$operationType = OperationCatalog::canonicalCode($operationType);
|
||||||
|
|
||||||
if ($operationType === '') {
|
if ($operationType === '') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return match ($operationType) {
|
return match ($operationType) {
|
||||||
'provider.connection.check', 'provider.inventory.sync', 'provider.compliance.snapshot' => Capabilities::PROVIDER_RUN,
|
'provider.connection.check', 'inventory.sync', 'compliance.snapshot' => Capabilities::PROVIDER_RUN,
|
||||||
'policy.sync', 'policy.sync_one', 'tenant.sync' => Capabilities::TENANT_SYNC,
|
'policy.sync', 'tenant.sync' => Capabilities::TENANT_SYNC,
|
||||||
'policy.delete' => Capabilities::TENANT_MANAGE,
|
'policy.delete' => Capabilities::TENANT_MANAGE,
|
||||||
'assignments.restore', 'restore.execute' => Capabilities::TENANT_MANAGE,
|
'assignments.restore', 'restore.execute' => Capabilities::TENANT_MANAGE,
|
||||||
'tenant.review.compose' => Capabilities::TENANT_REVIEW_MANAGE,
|
'tenant.review.compose' => Capabilities::TENANT_REVIEW_MANAGE,
|
||||||
|
|||||||
@ -567,7 +567,7 @@ private static function terminalSupportingLines(OperationRun $run): array
|
|||||||
|
|
||||||
private static function baselineTruthChangeLine(OperationRun $run): ?string
|
private static function baselineTruthChangeLine(OperationRun $run): ?string
|
||||||
{
|
{
|
||||||
if ((string) $run->type !== 'baseline_capture') {
|
if ($run->canonicalOperationType() !== 'baseline.capture') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -20,7 +20,7 @@
|
|||||||
'schedule_minutes' => (int) env('TENANTPILOT_OPERATION_RUN_RECONCILIATION_SCHEDULE_MINUTES', 5),
|
'schedule_minutes' => (int) env('TENANTPILOT_OPERATION_RUN_RECONCILIATION_SCHEDULE_MINUTES', 5),
|
||||||
],
|
],
|
||||||
'covered_types' => [
|
'covered_types' => [
|
||||||
'baseline_capture' => [
|
'baseline.capture' => [
|
||||||
'job_class' => \App\Jobs\CaptureBaselineSnapshotJob::class,
|
'job_class' => \App\Jobs\CaptureBaselineSnapshotJob::class,
|
||||||
'queued_stale_after_seconds' => 600,
|
'queued_stale_after_seconds' => 600,
|
||||||
'running_stale_after_seconds' => 1800,
|
'running_stale_after_seconds' => 1800,
|
||||||
@ -28,7 +28,7 @@
|
|||||||
'direct_failed_bridge' => true,
|
'direct_failed_bridge' => true,
|
||||||
'scheduled_reconciliation' => true,
|
'scheduled_reconciliation' => true,
|
||||||
],
|
],
|
||||||
'baseline_compare' => [
|
'baseline.compare' => [
|
||||||
'job_class' => \App\Jobs\CompareBaselineToTenantJob::class,
|
'job_class' => \App\Jobs\CompareBaselineToTenantJob::class,
|
||||||
'queued_stale_after_seconds' => 600,
|
'queued_stale_after_seconds' => 600,
|
||||||
'running_stale_after_seconds' => 1800,
|
'running_stale_after_seconds' => 1800,
|
||||||
@ -36,7 +36,7 @@
|
|||||||
'direct_failed_bridge' => true,
|
'direct_failed_bridge' => true,
|
||||||
'scheduled_reconciliation' => true,
|
'scheduled_reconciliation' => true,
|
||||||
],
|
],
|
||||||
'inventory_sync' => [
|
'inventory.sync' => [
|
||||||
'job_class' => \App\Jobs\RunInventorySyncJob::class,
|
'job_class' => \App\Jobs\RunInventorySyncJob::class,
|
||||||
'queued_stale_after_seconds' => 300,
|
'queued_stale_after_seconds' => 300,
|
||||||
'running_stale_after_seconds' => 1200,
|
'running_stale_after_seconds' => 1200,
|
||||||
@ -52,15 +52,7 @@
|
|||||||
'direct_failed_bridge' => true,
|
'direct_failed_bridge' => true,
|
||||||
'scheduled_reconciliation' => true,
|
'scheduled_reconciliation' => true,
|
||||||
],
|
],
|
||||||
'policy.sync_one' => [
|
'directory.groups.sync' => [
|
||||||
'job_class' => \App\Jobs\SyncPoliciesJob::class,
|
|
||||||
'queued_stale_after_seconds' => 300,
|
|
||||||
'running_stale_after_seconds' => 900,
|
|
||||||
'expected_max_runtime_seconds' => 180,
|
|
||||||
'direct_failed_bridge' => true,
|
|
||||||
'scheduled_reconciliation' => true,
|
|
||||||
],
|
|
||||||
'entra_group_sync' => [
|
|
||||||
'job_class' => \App\Jobs\EntraGroupSyncJob::class,
|
'job_class' => \App\Jobs\EntraGroupSyncJob::class,
|
||||||
'queued_stale_after_seconds' => 300,
|
'queued_stale_after_seconds' => 300,
|
||||||
'running_stale_after_seconds' => 900,
|
'running_stale_after_seconds' => 900,
|
||||||
@ -68,7 +60,7 @@
|
|||||||
'direct_failed_bridge' => false,
|
'direct_failed_bridge' => false,
|
||||||
'scheduled_reconciliation' => true,
|
'scheduled_reconciliation' => true,
|
||||||
],
|
],
|
||||||
'directory_role_definitions.sync' => [
|
'directory.role_definitions.sync' => [
|
||||||
'job_class' => \App\Jobs\SyncRoleDefinitionsJob::class,
|
'job_class' => \App\Jobs\SyncRoleDefinitionsJob::class,
|
||||||
'queued_stale_after_seconds' => 300,
|
'queued_stale_after_seconds' => 300,
|
||||||
'running_stale_after_seconds' => 900,
|
'running_stale_after_seconds' => 900,
|
||||||
@ -84,7 +76,7 @@
|
|||||||
'direct_failed_bridge' => false,
|
'direct_failed_bridge' => false,
|
||||||
'scheduled_reconciliation' => true,
|
'scheduled_reconciliation' => true,
|
||||||
],
|
],
|
||||||
'backup_schedule_run' => [
|
'backup.schedule.execute' => [
|
||||||
'job_class' => \App\Jobs\RunBackupScheduleJob::class,
|
'job_class' => \App\Jobs\RunBackupScheduleJob::class,
|
||||||
'queued_stale_after_seconds' => 300,
|
'queued_stale_after_seconds' => 300,
|
||||||
'running_stale_after_seconds' => 1200,
|
'running_stale_after_seconds' => 1200,
|
||||||
|
|||||||
@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
use App\Support\Governance\PlatformVocabularyGlossary;
|
use App\Support\Governance\PlatformVocabularyGlossary;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
|
use App\Services\Providers\ProviderOperationRegistry;
|
||||||
|
|
||||||
it('keeps touched registry ownership metadata inside the allowed three-way boundary classification', function (): void {
|
it('keeps touched registry ownership metadata inside the allowed three-way boundary classification', function (): void {
|
||||||
$classifications = collect(app(PlatformVocabularyGlossary::class)->registries())
|
$classifications = collect(app(PlatformVocabularyGlossary::class)->registries())
|
||||||
@ -25,4 +27,31 @@
|
|||||||
expect($glossary->term('policy_type')?->boundaryClassification)->toBe(PlatformVocabularyGlossary::BOUNDARY_INTUNE_SPECIFIC)
|
expect($glossary->term('policy_type')?->boundaryClassification)->toBe(PlatformVocabularyGlossary::BOUNDARY_INTUNE_SPECIFIC)
|
||||||
->and($glossary->resolveAlias('policy_type', 'compare')?->termKey)->toBe('governed_subject')
|
->and($glossary->resolveAlias('policy_type', 'compare')?->termKey)->toBe('governed_subject')
|
||||||
->and(OperationCatalog::canonicalCode('baseline_capture'))->toBe('baseline.capture');
|
->and(OperationCatalog::canonicalCode('baseline_capture'))->toBe('baseline.capture');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('guards canonical operation type writers against raw alias reintroduction', function (): void {
|
||||||
|
$legacyAliases = [
|
||||||
|
'baseline_capture',
|
||||||
|
'baseline_compare',
|
||||||
|
'inventory_sync',
|
||||||
|
'entra_group_sync',
|
||||||
|
'backup_schedule_run',
|
||||||
|
'backup_schedule_retention',
|
||||||
|
'backup_schedule_purge',
|
||||||
|
'directory_role_definitions.sync',
|
||||||
|
];
|
||||||
|
|
||||||
|
$registry = app(ProviderOperationRegistry::class);
|
||||||
|
$registryTypes = array_merge(
|
||||||
|
array_keys($registry->definitions()),
|
||||||
|
collect($registry->definitions())->pluck('operation_type')->all(),
|
||||||
|
array_keys($registry->providerBindings()),
|
||||||
|
collect($registry->providerBindings())
|
||||||
|
->flatMap(static fn (array $bindings): array => collect($bindings)->pluck('operation_type')->all())
|
||||||
|
->all(),
|
||||||
|
OperationRunType::values(),
|
||||||
|
array_keys(config('tenantpilot.operations.lifecycle.covered_types', [])),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(array_values(array_intersect($legacyAliases, $registryTypes)))->toBe([]);
|
||||||
|
});
|
||||||
|
|||||||
@ -82,7 +82,7 @@
|
|||||||
|
|
||||||
$retentionRun = OperationRun::query()
|
$retentionRun = OperationRun::query()
|
||||||
->where('tenant_id', (int) $tenant->id)
|
->where('tenant_id', (int) $tenant->id)
|
||||||
->where('type', 'backup_schedule_retention')
|
->where('type', 'backup.schedule.retention')
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
|||||||
@ -37,7 +37,7 @@
|
|||||||
|
|
||||||
expect(OperationRun::query()
|
expect(OperationRun::query()
|
||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
->where('type', 'backup_schedule_run')
|
->where('type', 'backup.schedule.execute')
|
||||||
->count())->toBe(1);
|
->count())->toBe(1);
|
||||||
|
|
||||||
Bus::assertDispatchedTimes(RunBackupScheduleJob::class, 1);
|
Bus::assertDispatchedTimes(RunBackupScheduleJob::class, 1);
|
||||||
@ -45,7 +45,7 @@
|
|||||||
Bus::assertDispatched(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($tenant): bool {
|
Bus::assertDispatched(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($tenant): bool {
|
||||||
return $job->backupScheduleId !== null
|
return $job->backupScheduleId !== null
|
||||||
&& $job->operationRun?->tenant_id === $tenant->getKey()
|
&& $job->operationRun?->tenant_id === $tenant->getKey()
|
||||||
&& $job->operationRun?->type === 'backup_schedule_run';
|
&& $job->operationRun?->type === 'backup.schedule.execute';
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -74,7 +74,7 @@
|
|||||||
|
|
||||||
$operationRunService->ensureRunWithIdentityStrict(
|
$operationRunService->ensureRunWithIdentityStrict(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'backup_schedule_run',
|
type: 'backup.schedule.execute',
|
||||||
identityInputs: [
|
identityInputs: [
|
||||||
'backup_schedule_id' => (int) $schedule->id,
|
'backup_schedule_id' => (int) $schedule->id,
|
||||||
'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(),
|
'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(),
|
||||||
@ -94,7 +94,7 @@
|
|||||||
|
|
||||||
expect(OperationRun::query()
|
expect(OperationRun::query()
|
||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
->where('type', 'backup_schedule_run')
|
->where('type', 'backup.schedule.execute')
|
||||||
->count())->toBe(1);
|
->count())->toBe(1);
|
||||||
|
|
||||||
$schedule->refresh();
|
$schedule->refresh();
|
||||||
@ -133,7 +133,7 @@
|
|||||||
->and($result['scanned_schedules'])->toBe(0)
|
->and($result['scanned_schedules'])->toBe(0)
|
||||||
->and(OperationRun::query()
|
->and(OperationRun::query()
|
||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
->where('type', 'backup_schedule_run')
|
->where('type', 'backup.schedule.execute')
|
||||||
->count())->toBe(0);
|
->count())->toBe(0);
|
||||||
|
|
||||||
Bus::assertNotDispatched(RunBackupScheduleJob::class);
|
Bus::assertNotDispatched(RunBackupScheduleJob::class);
|
||||||
|
|||||||
@ -48,7 +48,7 @@
|
|||||||
|
|
||||||
$operationRun = OperationRun::query()
|
$operationRun = OperationRun::query()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('tenant_id', $tenant->id)
|
||||||
->where('type', 'backup_schedule_run')
|
->where('type', 'backup.schedule.execute')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
expect($operationRun)->not->toBeNull();
|
expect($operationRun)->not->toBeNull();
|
||||||
@ -96,7 +96,7 @@
|
|||||||
|
|
||||||
$runs = OperationRun::query()
|
$runs = OperationRun::query()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('tenant_id', $tenant->id)
|
||||||
->where('type', 'backup_schedule_run')
|
->where('type', 'backup.schedule.execute')
|
||||||
->pluck('id')
|
->pluck('id')
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
@ -133,7 +133,7 @@
|
|||||||
|
|
||||||
$operationRun = OperationRun::query()
|
$operationRun = OperationRun::query()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('tenant_id', $tenant->id)
|
||||||
->where('type', 'backup_schedule_run')
|
->where('type', 'backup.schedule.execute')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
expect($operationRun)->not->toBeNull();
|
expect($operationRun)->not->toBeNull();
|
||||||
@ -180,7 +180,7 @@
|
|||||||
|
|
||||||
$runs = OperationRun::query()
|
$runs = OperationRun::query()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('tenant_id', $tenant->id)
|
||||||
->where('type', 'backup_schedule_run')
|
->where('type', 'backup.schedule.execute')
|
||||||
->pluck('id')
|
->pluck('id')
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
@ -226,7 +226,7 @@
|
|||||||
|
|
||||||
expect(OperationRun::query()
|
expect(OperationRun::query()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('tenant_id', $tenant->id)
|
||||||
->whereIn('type', ['backup_schedule_run', 'backup_schedule_run'])
|
->whereIn('type', ['backup.schedule.execute'])
|
||||||
->count())
|
->count())
|
||||||
->toBe(0);
|
->toBe(0);
|
||||||
});
|
});
|
||||||
@ -270,13 +270,13 @@
|
|||||||
|
|
||||||
expect(OperationRun::query()
|
expect(OperationRun::query()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('tenant_id', $tenant->id)
|
||||||
->where('type', 'backup_schedule_run')
|
->where('type', 'backup.schedule.execute')
|
||||||
->count())
|
->count())
|
||||||
->toBe(2);
|
->toBe(2);
|
||||||
|
|
||||||
expect(OperationRun::query()
|
expect(OperationRun::query()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('tenant_id', $tenant->id)
|
||||||
->where('type', 'backup_schedule_run')
|
->where('type', 'backup.schedule.execute')
|
||||||
->pluck('user_id')
|
->pluck('user_id')
|
||||||
->unique()
|
->unique()
|
||||||
->values()
|
->values()
|
||||||
@ -326,13 +326,13 @@
|
|||||||
|
|
||||||
expect(OperationRun::query()
|
expect(OperationRun::query()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('tenant_id', $tenant->id)
|
||||||
->where('type', 'backup_schedule_run')
|
->where('type', 'backup.schedule.execute')
|
||||||
->count())
|
->count())
|
||||||
->toBe(2);
|
->toBe(2);
|
||||||
|
|
||||||
expect(OperationRun::query()
|
expect(OperationRun::query()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('tenant_id', $tenant->id)
|
||||||
->where('type', 'backup_schedule_run')
|
->where('type', 'backup.schedule.execute')
|
||||||
->pluck('user_id')
|
->pluck('user_id')
|
||||||
->unique()
|
->unique()
|
||||||
->values()
|
->values()
|
||||||
@ -382,7 +382,7 @@
|
|||||||
$operationRunService = app(OperationRunService::class);
|
$operationRunService = app(OperationRunService::class);
|
||||||
$existing = $operationRunService->ensureRunWithIdentity(
|
$existing = $operationRunService->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'backup_schedule_run',
|
type: 'backup.schedule.execute',
|
||||||
identityInputs: [
|
identityInputs: [
|
||||||
'backup_schedule_id' => (int) $scheduleA->getKey(),
|
'backup_schedule_id' => (int) $scheduleA->getKey(),
|
||||||
'nonce' => 'existing',
|
'nonce' => 'existing',
|
||||||
@ -403,7 +403,7 @@
|
|||||||
|
|
||||||
expect(OperationRun::query()
|
expect(OperationRun::query()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('tenant_id', $tenant->id)
|
||||||
->where('type', 'backup_schedule_run')
|
->where('type', 'backup.schedule.execute')
|
||||||
->count())
|
->count())
|
||||||
->toBe(3);
|
->toBe(3);
|
||||||
|
|
||||||
|
|||||||
@ -129,12 +129,12 @@
|
|||||||
expect(OperationRun::query()->where('tenant_id', $tenantA->id)->count())->toBe(1);
|
expect(OperationRun::query()->where('tenant_id', $tenantA->id)->count())->toBe(1);
|
||||||
expect(OperationRun::query()
|
expect(OperationRun::query()
|
||||||
->where('tenant_id', $tenantA->id)
|
->where('tenant_id', $tenantA->id)
|
||||||
->where('type', 'backup_schedule_purge')
|
->where('type', 'backup.schedule.purge')
|
||||||
->exists())->toBeTrue();
|
->exists())->toBeTrue();
|
||||||
|
|
||||||
$purgeRun = OperationRun::query()
|
$purgeRun = OperationRun::query()
|
||||||
->where('tenant_id', $tenantA->id)
|
->where('tenant_id', $tenantA->id)
|
||||||
->where('type', 'backup_schedule_purge')
|
->where('type', 'backup.schedule.purge')
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
|||||||
@ -39,7 +39,7 @@
|
|||||||
|
|
||||||
$run = OperationRun::query()
|
$run = OperationRun::query()
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
->where('type', 'entra_group_sync')
|
->where('type', 'directory.groups.sync')
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
|||||||
@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
$opRun = OperationRun::query()
|
$opRun = OperationRun::query()
|
||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
->where('type', 'entra_group_sync')
|
->where('type', 'directory.groups.sync')
|
||||||
->where('context->slot_key', $slotKey)
|
->where('context->slot_key', $slotKey)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
|||||||
@ -31,7 +31,7 @@
|
|||||||
|
|
||||||
$opRun = OperationRun::query()
|
$opRun = OperationRun::query()
|
||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
->where('type', 'entra_group_sync')
|
->where('type', 'directory.groups.sync')
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
|||||||
@ -203,6 +203,33 @@ function operationRunFilterIndicatorLabels($component): array
|
|||||||
->assertCanNotSeeTableRecords([$otherRun]);
|
->assertCanNotSeeTableRecords([$otherRun]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows one canonical operation filter option for current and historical inventory values', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
foreach (['inventory.sync', 'inventory_sync', 'provider.inventory.sync'] as $type) {
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => $type,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||||
|
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($user)
|
||||||
|
->test(Operations::class);
|
||||||
|
|
||||||
|
/** @var SelectFilter|null $filter */
|
||||||
|
$filter = $component->instance()->getTable()->getFilter('type');
|
||||||
|
|
||||||
|
expect($filter)->not->toBeNull();
|
||||||
|
expect($filter?->getOptions())->toBe([
|
||||||
|
'inventory.sync' => 'Inventory sync',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('clears tenant-sensitive persisted filters when the canonical tenant context changes', function (): void {
|
it('clears tenant-sensitive persisted filters when the canonical tenant context changes', function (): void {
|
||||||
$tenantA = Tenant::factory()->create();
|
$tenantA = Tenant::factory()->create();
|
||||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use Tests\Support\OpsUx\SourceFileScanner;
|
use Tests\Support\OpsUx\SourceFileScanner;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
@ -158,3 +159,10 @@ function operationRunLinkContractViolations(array $paths, array $allowlist = [])
|
|||||||
->and($violations[0]['expectedHelper'])->toContain('OperationRunLinks::view')
|
->and($violations[0]['expectedHelper'])->toContain('OperationRunLinks::view')
|
||||||
->and($violations[0]['reason'])->toContain('bypasses the canonical admin link helper');
|
->and($violations[0]['reason'])->toContain('bypasses the canonical admin link helper');
|
||||||
})->group('surface-guard');
|
})->group('surface-guard');
|
||||||
|
|
||||||
|
it('canonicalizes operation type query parameters for operation collection links', function (): void {
|
||||||
|
$url = OperationRunLinks::index(operationType: 'inventory_sync');
|
||||||
|
|
||||||
|
expect($url)->toContain('inventory.sync')
|
||||||
|
->not->toContain('inventory_sync');
|
||||||
|
})->group('surface-guard');
|
||||||
|
|||||||
@ -57,7 +57,7 @@ function providerDispatchGateSlice(string $source, string $startAnchor, ?string
|
|||||||
'end' => 'public function sync(',
|
'end' => 'public function sync(',
|
||||||
'required' => [
|
'required' => [
|
||||||
'return $this->providerStarts->start(',
|
'return $this->providerStarts->start(',
|
||||||
"operationType: 'entra_group_sync'",
|
'operationType: OperationRunType::DirectoryGroupsSync->value',
|
||||||
'EntraGroupSyncJob::dispatch(',
|
'EntraGroupSyncJob::dispatch(',
|
||||||
'->afterCommit()',
|
'->afterCommit()',
|
||||||
],
|
],
|
||||||
@ -69,7 +69,7 @@ function providerDispatchGateSlice(string $source, string $startAnchor, ?string
|
|||||||
'end' => 'public function sync(',
|
'end' => 'public function sync(',
|
||||||
'required' => [
|
'required' => [
|
||||||
'return $this->providerStarts->start(',
|
'return $this->providerStarts->start(',
|
||||||
"operationType: 'directory_role_definitions.sync'",
|
'operationType: OperationRunType::DirectoryRoleDefinitionsSync->value',
|
||||||
'SyncRoleDefinitionsJob::dispatch(',
|
'SyncRoleDefinitionsJob::dispatch(',
|
||||||
'->afterCommit()',
|
'->afterCommit()',
|
||||||
],
|
],
|
||||||
|
|||||||
@ -35,7 +35,7 @@
|
|||||||
$opRun = OperationRun::query()
|
$opRun = OperationRun::query()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('tenant_id', $tenant->id)
|
||||||
->where('user_id', $user->id)
|
->where('user_id', $user->id)
|
||||||
->where('type', 'inventory_sync')
|
->where('type', 'inventory.sync')
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
@ -70,7 +70,7 @@
|
|||||||
|
|
||||||
$opRun = OperationRun::query()
|
$opRun = OperationRun::query()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('tenant_id', $tenant->id)
|
||||||
->where('type', 'inventory_sync')
|
->where('type', 'inventory.sync')
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
@ -101,7 +101,7 @@
|
|||||||
|
|
||||||
$opRun = OperationRun::query()
|
$opRun = OperationRun::query()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('tenant_id', $tenant->id)
|
||||||
->where('type', 'inventory_sync')
|
->where('type', 'inventory.sync')
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
@ -132,7 +132,7 @@
|
|||||||
|
|
||||||
$opRun = OperationRun::query()
|
$opRun = OperationRun::query()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('tenant_id', $tenant->id)
|
||||||
->where('type', 'inventory_sync')
|
->where('type', 'inventory.sync')
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
@ -189,7 +189,7 @@
|
|||||||
|
|
||||||
$opRun = OperationRun::query()
|
$opRun = OperationRun::query()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('tenant_id', $tenant->id)
|
||||||
->where('type', 'inventory_sync')
|
->where('type', 'inventory.sync')
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
@ -216,7 +216,7 @@
|
|||||||
|
|
||||||
Queue::assertNothingPushed();
|
Queue::assertNothingPushed();
|
||||||
|
|
||||||
expect(OperationRun::query()->where('tenant_id', $tenantB->id)->where('type', 'inventory_sync')->exists())->toBeFalse();
|
expect(OperationRun::query()->where('tenant_id', $tenantB->id)->where('type', 'inventory.sync')->exists())->toBeFalse();
|
||||||
expect(OperationRun::query()->where('tenant_id', $tenantB->id)->exists())->toBeFalse();
|
expect(OperationRun::query()->where('tenant_id', $tenantB->id)->exists())->toBeFalse();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -234,7 +234,7 @@
|
|||||||
$opService = app(OperationRunService::class);
|
$opService = app(OperationRunService::class);
|
||||||
$existing = $opService->ensureRunWithIdentity(
|
$existing = $opService->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'inventory_sync',
|
type: 'inventory.sync',
|
||||||
identityInputs: [
|
identityInputs: [
|
||||||
'selection_hash' => $computed['selection_hash'],
|
'selection_hash' => $computed['selection_hash'],
|
||||||
],
|
],
|
||||||
@ -253,7 +253,7 @@
|
|||||||
->callAction('run_inventory_sync', data: ['policy_types' => $computed['selection']['policy_types']]);
|
->callAction('run_inventory_sync', data: ['policy_types' => $computed['selection']['policy_types']]);
|
||||||
|
|
||||||
Queue::assertNothingPushed();
|
Queue::assertNothingPushed();
|
||||||
expect(OperationRun::query()->where('tenant_id', $tenant->id)->where('type', 'inventory_sync')->count())->toBe(1);
|
expect(OperationRun::query()->where('tenant_id', $tenant->id)->where('type', 'inventory.sync')->count())->toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('disables inventory sync start action for readonly users', function () {
|
it('disables inventory sync start action for readonly users', function () {
|
||||||
@ -268,5 +268,5 @@
|
|||||||
->assertActionDisabled('run_inventory_sync');
|
->assertActionDisabled('run_inventory_sync');
|
||||||
|
|
||||||
Queue::assertNothingPushed();
|
Queue::assertNothingPushed();
|
||||||
expect(OperationRun::query()->where('tenant_id', $tenant->id)->where('type', 'inventory_sync')->exists())->toBeFalse();
|
expect(OperationRun::query()->where('tenant_id', $tenant->id)->where('type', 'inventory.sync')->exists())->toBeFalse();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -116,7 +116,7 @@ function executeInventorySyncNow(Tenant $tenant, array $selection): array
|
|||||||
|
|
||||||
$opRun = $opService->ensureRunWithIdentity(
|
$opRun = $opService->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'inventory_sync',
|
type: 'inventory.sync',
|
||||||
identityInputs: [
|
identityInputs: [
|
||||||
'selection_hash' => $computed['selection_hash'],
|
'selection_hash' => $computed['selection_hash'],
|
||||||
],
|
],
|
||||||
@ -617,7 +617,7 @@ function executeInventorySyncNow(Tenant $tenant, array $selection): array
|
|||||||
];
|
];
|
||||||
|
|
||||||
$hash = app(\App\Services\Inventory\InventorySelectionHasher::class)->hash($selection);
|
$hash = app(\App\Services\Inventory\InventorySelectionHasher::class)->hash($selection);
|
||||||
$lock = Cache::lock("inventory_sync:tenant:{$tenant->id}:selection:{$hash}", 900);
|
$lock = Cache::lock("inventory.sync:tenant:{$tenant->id}:selection:{$hash}", 900);
|
||||||
expect($lock->get())->toBeTrue();
|
expect($lock->get())->toBeTrue();
|
||||||
|
|
||||||
$run = executeInventorySyncNow($tenant, $selection);
|
$run = executeInventorySyncNow($tenant, $selection);
|
||||||
|
|||||||
@ -40,7 +40,7 @@
|
|||||||
|
|
||||||
$opRun = OperationRun::query()
|
$opRun = OperationRun::query()
|
||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
->where('type', 'inventory_sync')
|
->where('type', 'inventory.sync')
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
|
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
|
||||||
|
use App\Models\AuditLog;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@ -10,6 +11,7 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\Providers\ProviderConnectionType;
|
use App\Support\Providers\ProviderConnectionType;
|
||||||
@ -384,7 +386,7 @@
|
|||||||
$bootstrapRun = OperationRun::factory()->create([
|
$bootstrapRun = OperationRun::factory()->create([
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'type' => 'inventory_sync',
|
'type' => 'inventory.sync',
|
||||||
'status' => OperationRunStatus::Completed->value,
|
'status' => OperationRunStatus::Completed->value,
|
||||||
'outcome' => OperationRunOutcome::Blocked->value,
|
'outcome' => OperationRunOutcome::Blocked->value,
|
||||||
'context' => [
|
'context' => [
|
||||||
@ -520,7 +522,7 @@
|
|||||||
|
|
||||||
$session->refresh();
|
$session->refresh();
|
||||||
|
|
||||||
expect($session->state['bootstrap_operation_types'] ?? null)->toBe(['inventory_sync', 'compliance.snapshot']);
|
expect($session->state['bootstrap_operation_types'] ?? null)->toBe(['inventory.sync', 'compliance.snapshot']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('filters unsupported bootstrap selections from persisted onboarding drafts', function (): void {
|
it('filters unsupported bootstrap selections from persisted onboarding drafts', function (): void {
|
||||||
@ -618,12 +620,12 @@
|
|||||||
'restore.execute',
|
'restore.execute',
|
||||||
'entra_group_sync',
|
'entra_group_sync',
|
||||||
'directory_role_definitions.sync',
|
'directory_role_definitions.sync',
|
||||||
]))->toBe(['inventory_sync', 'compliance.snapshot']);
|
]))->toBe(['inventory.sync', 'compliance.snapshot']);
|
||||||
|
|
||||||
$optionsMethod = new \ReflectionMethod($component->instance(), 'bootstrapOperationOptions');
|
$optionsMethod = new \ReflectionMethod($component->instance(), 'bootstrapOperationOptions');
|
||||||
$optionsMethod->setAccessible(true);
|
$optionsMethod->setAccessible(true);
|
||||||
|
|
||||||
expect(array_keys($optionsMethod->invoke($component->instance())))->toBe(['inventory_sync', 'compliance.snapshot']);
|
expect(array_keys($optionsMethod->invoke($component->instance())))->toBe(['inventory.sync', 'compliance.snapshot']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns resumable drafts with missing provider connections to the provider connection step', function (): void {
|
it('returns resumable drafts with missing provider connections to the provider connection step', function (): void {
|
||||||
@ -1464,7 +1466,7 @@
|
|||||||
|
|
||||||
expect(OperationRun::query()
|
expect(OperationRun::query()
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
->where('type', 'inventory_sync')
|
->where('type', 'inventory.sync')
|
||||||
->count())->toBe(1);
|
->count())->toBe(1);
|
||||||
|
|
||||||
expect(OperationRun::query()
|
expect(OperationRun::query()
|
||||||
@ -1475,9 +1477,17 @@
|
|||||||
$session->refresh();
|
$session->refresh();
|
||||||
$runs = $session->state['bootstrap_operation_runs'] ?? [];
|
$runs = $session->state['bootstrap_operation_runs'] ?? [];
|
||||||
expect($runs)->toBeArray();
|
expect($runs)->toBeArray();
|
||||||
expect($runs['inventory_sync'] ?? null)->toBeInt();
|
expect($runs['inventory.sync'] ?? null)->toBeInt();
|
||||||
expect($runs['compliance.snapshot'] ?? null)->toBeNull();
|
expect($runs['compliance.snapshot'] ?? null)->toBeNull();
|
||||||
expect($session->state['bootstrap_operation_types'] ?? null)->toBe(['inventory_sync', 'compliance.snapshot']);
|
expect($session->state['bootstrap_operation_types'] ?? null)->toBe(['inventory.sync', 'compliance.snapshot']);
|
||||||
|
|
||||||
|
$audit = AuditLog::query()
|
||||||
|
->where('action', AuditActionId::ManagedTenantOnboardingBootstrapStarted->value)
|
||||||
|
->latest('id')
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
expect(data_get($audit->metadata, 'operation_types'))->toBe(['inventory.sync', 'compliance.snapshot'])
|
||||||
|
->and(data_get($audit->metadata, 'started_operation_type'))->toBe('inventory.sync');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('starts the next pending bootstrap action after the prior one completes successfully', function (): void {
|
it('starts the next pending bootstrap action after the prior one completes successfully', function (): void {
|
||||||
@ -1533,11 +1543,11 @@
|
|||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']);
|
$component->call('startBootstrap', ['inventory.sync', 'compliance.snapshot']);
|
||||||
|
|
||||||
$inventoryRun = OperationRun::query()
|
$inventoryRun = OperationRun::query()
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
->where('type', 'inventory_sync')
|
->where('type', 'inventory.sync')
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
@ -1546,7 +1556,7 @@
|
|||||||
'outcome' => 'succeeded',
|
'outcome' => 'succeeded',
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
$component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']);
|
$component->call('startBootstrap', ['inventory.sync', 'compliance.snapshot']);
|
||||||
|
|
||||||
Bus::assertDispatchedTimes(\App\Jobs\ProviderInventorySyncJob::class, 1);
|
Bus::assertDispatchedTimes(\App\Jobs\ProviderInventorySyncJob::class, 1);
|
||||||
Bus::assertDispatchedTimes(\App\Jobs\ProviderComplianceSnapshotJob::class, 1);
|
Bus::assertDispatchedTimes(\App\Jobs\ProviderComplianceSnapshotJob::class, 1);
|
||||||
@ -1558,7 +1568,7 @@
|
|||||||
|
|
||||||
$session->refresh();
|
$session->refresh();
|
||||||
$runs = $session->state['bootstrap_operation_runs'] ?? [];
|
$runs = $session->state['bootstrap_operation_runs'] ?? [];
|
||||||
expect($runs['inventory_sync'] ?? null)->toBeInt();
|
expect($runs['inventory.sync'] ?? null)->toBeInt();
|
||||||
expect($runs['compliance.snapshot'] ?? null)->toBeInt();
|
expect($runs['compliance.snapshot'] ?? null)->toBeInt();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1592,7 +1602,7 @@
|
|||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'user_id' => (int) $user->getKey(),
|
'user_id' => (int) $user->getKey(),
|
||||||
'initiator_name' => $user->name,
|
'initiator_name' => $user->name,
|
||||||
'type' => 'inventory_sync',
|
'type' => 'inventory.sync',
|
||||||
'status' => 'queued',
|
'status' => 'queued',
|
||||||
'outcome' => 'pending',
|
'outcome' => 'pending',
|
||||||
'run_identity_hash' => sha1('busy-'.(string) $connection->getKey()),
|
'run_identity_hash' => sha1('busy-'.(string) $connection->getKey()),
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Models\AuditLog;
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Services\Evidence\Sources\OperationsSummarySource;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\Audit\AuditOutcome;
|
use App\Support\Audit\AuditOutcome;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
@ -15,7 +17,7 @@
|
|||||||
|
|
||||||
$run = $service->ensureRunWithIdentity(
|
$run = $service->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'inventory_sync',
|
type: 'inventory.sync',
|
||||||
identityInputs: ['selection_hash' => 'abc123'],
|
identityInputs: ['selection_hash' => 'abc123'],
|
||||||
context: ['selection_hash' => 'abc123'],
|
context: ['selection_hash' => 'abc123'],
|
||||||
initiator: $user,
|
initiator: $user,
|
||||||
@ -46,7 +48,7 @@
|
|||||||
->and((string) $audit?->resource_id)->toBe((string) $run->getKey())
|
->and((string) $audit?->resource_id)->toBe((string) $run->getKey())
|
||||||
->and($audit?->normalizedOutcome())->toBe(AuditOutcome::Success)
|
->and($audit?->normalizedOutcome())->toBe(AuditOutcome::Success)
|
||||||
->and($audit?->actorDisplayLabel())->toBe($user->name)
|
->and($audit?->actorDisplayLabel())->toBe($user->name)
|
||||||
->and(data_get($audit?->metadata, 'operation_type'))->toBe('inventory_sync');
|
->and(data_get($audit?->metadata, 'operation_type'))->toBe('inventory.sync');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('writes blocked terminal audit semantics for blocked runs', function (): void {
|
it('writes blocked terminal audit semantics for blocked runs', function (): void {
|
||||||
@ -80,3 +82,23 @@
|
|||||||
->and($audit?->normalizedOutcome())->toBe(AuditOutcome::Blocked)
|
->and($audit?->normalizedOutcome())->toBe(AuditOutcome::Blocked)
|
||||||
->and(data_get($audit?->metadata, 'failure_summary.0.reason_code'))->toBe('intune_rbac.not_configured');
|
->and(data_get($audit?->metadata, 'failure_summary.0.reason_code'))->toBe('intune_rbac.not_configured');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('emits canonical operation types in operations evidence summaries for historical rows', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'type' => 'inventory_sync',
|
||||||
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
|
'created_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$payload = app(OperationsSummarySource::class)->collect($tenant);
|
||||||
|
$entry = data_get($payload, 'summary_payload.entries.0');
|
||||||
|
|
||||||
|
expect($entry)->toBeArray()
|
||||||
|
->and($entry['operation_type'] ?? null)->toBe('inventory.sync')
|
||||||
|
->and($entry['type'] ?? null)->toBe('inventory_sync');
|
||||||
|
});
|
||||||
|
|||||||
@ -74,7 +74,7 @@ function () use (&$terminalInvoked): string {
|
|||||||
->and($run->context['reason_code'] ?? null)->toBe('missing_capability')
|
->and($run->context['reason_code'] ?? null)->toBe('missing_capability')
|
||||||
->and($run->context['execution_legitimacy']['metadata']['required_capability'] ?? null)->toBe(Capabilities::PROVIDER_RUN);
|
->and($run->context['execution_legitimacy']['metadata']['required_capability'] ?? null)->toBe(Capabilities::PROVIDER_RUN);
|
||||||
})->with([
|
})->with([
|
||||||
'provider inventory sync' => ['inventory_sync', ProviderInventorySyncJob::class],
|
'provider inventory sync' => ['inventory.sync', ProviderInventorySyncJob::class],
|
||||||
'provider compliance snapshot' => ['compliance.snapshot', ProviderComplianceSnapshotJob::class],
|
'provider compliance snapshot' => ['compliance.snapshot', ProviderComplianceSnapshotJob::class],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -139,7 +139,7 @@ function () use (&$terminalInvoked): string {
|
|||||||
|
|
||||||
$run = app(OperationRunService::class)->ensureRunWithIdentityStrict(
|
$run = app(OperationRunService::class)->ensureRunWithIdentityStrict(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'backup_schedule_run',
|
type: 'backup.schedule.execute',
|
||||||
identityInputs: [
|
identityInputs: [
|
||||||
'backup_schedule_id' => (int) $schedule->getKey(),
|
'backup_schedule_id' => (int) $schedule->getKey(),
|
||||||
'scheduled_for' => now()->toDateTimeString(),
|
'scheduled_for' => now()->toDateTimeString(),
|
||||||
@ -190,7 +190,7 @@ function () use (&$terminalInvoked): string {
|
|||||||
|
|
||||||
$scheduleRun = app(OperationRunService::class)->ensureRunWithIdentityStrict(
|
$scheduleRun = app(OperationRunService::class)->ensureRunWithIdentityStrict(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'backup_schedule_run',
|
type: 'backup.schedule.execute',
|
||||||
identityInputs: [
|
identityInputs: [
|
||||||
'backup_schedule_id' => 99,
|
'backup_schedule_id' => 99,
|
||||||
'scheduled_for' => now()->toDateTimeString(),
|
'scheduled_for' => now()->toDateTimeString(),
|
||||||
@ -211,7 +211,7 @@ function () use (&$terminalInvoked): string {
|
|||||||
$result = app(ProviderOperationStartGate::class)->start(
|
$result = app(ProviderOperationStartGate::class)->start(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
connection: $connection,
|
connection: $connection,
|
||||||
operationType: 'inventory_sync',
|
operationType: 'inventory.sync',
|
||||||
dispatcher: static fn (OperationRun $run): null => null,
|
dispatcher: static fn (OperationRun $run): null => null,
|
||||||
initiator: $user,
|
initiator: $user,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -43,7 +43,7 @@ function runQueuedInventoryJobThroughMiddleware(object $job, Closure $terminal):
|
|||||||
|
|
||||||
$opRun = OperationRun::query()
|
$opRun = OperationRun::query()
|
||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
->where('type', 'inventory_sync')
|
->where('type', 'inventory.sync')
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
|||||||
@ -15,3 +15,9 @@
|
|||||||
expect(OperationCatalog::label('rbac.health_check'))
|
expect(OperationCatalog::label('rbac.health_check'))
|
||||||
->toBe('RBAC health check');
|
->toBe('RBAC health check');
|
||||||
})->group('ops-ux');
|
})->group('ops-ux');
|
||||||
|
|
||||||
|
it('does not normalize unknown values to nearby canonical operation labels', function (): void {
|
||||||
|
expect(OperationCatalog::label('inventory-sync'))
|
||||||
|
->toBe('Unknown operation')
|
||||||
|
->not->toBe(OperationCatalog::label('inventory.sync'));
|
||||||
|
})->group('ops-ux');
|
||||||
|
|||||||
@ -37,7 +37,7 @@
|
|||||||
|
|
||||||
$inventoryRun = OperationRun::query()
|
$inventoryRun = OperationRun::query()
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
->where('type', 'inventory_sync')
|
->where('type', 'inventory.sync')
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
|||||||
@ -43,7 +43,7 @@
|
|||||||
|
|
||||||
$opRun = OperationRun::query()
|
$opRun = OperationRun::query()
|
||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
->where('type', 'inventory_sync')
|
->where('type', 'inventory.sync')
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
@ -59,7 +59,7 @@
|
|||||||
|
|
||||||
expect(OperationRun::query()
|
expect(OperationRun::query()
|
||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
->where('type', 'inventory_sync')
|
->where('type', 'inventory.sync')
|
||||||
->count())->toBe(1);
|
->count())->toBe(1);
|
||||||
|
|
||||||
Queue::assertPushed(ProviderInventorySyncJob::class, 1);
|
Queue::assertPushed(ProviderInventorySyncJob::class, 1);
|
||||||
@ -96,7 +96,7 @@
|
|||||||
|
|
||||||
$opRun = OperationRun::query()
|
$opRun = OperationRun::query()
|
||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
->where('type', 'inventory_sync')
|
->where('type', 'inventory.sync')
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
@ -189,7 +189,7 @@
|
|||||||
|
|
||||||
$inventoryRun = OperationRun::query()
|
$inventoryRun = OperationRun::query()
|
||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
->where('type', 'inventory_sync')
|
->where('type', 'inventory.sync')
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
|||||||
@ -57,7 +57,7 @@
|
|||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'user_id' => (int) $user->getKey(),
|
'user_id' => (int) $user->getKey(),
|
||||||
'initiator_name' => $user->name,
|
'initiator_name' => $user->name,
|
||||||
'type' => 'inventory_sync',
|
'type' => 'inventory.sync',
|
||||||
'status' => 'queued',
|
'status' => 'queued',
|
||||||
'outcome' => 'pending',
|
'outcome' => 'pending',
|
||||||
'run_identity_hash' => sha1('busy-onboarding-'.(string) $connection->getKey()),
|
'run_identity_hash' => sha1('busy-onboarding-'.(string) $connection->getKey()),
|
||||||
@ -141,7 +141,7 @@
|
|||||||
|
|
||||||
$inventoryRun = OperationRun::query()
|
$inventoryRun = OperationRun::query()
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
->where('type', 'inventory_sync')
|
->where('type', 'inventory.sync')
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
@ -155,7 +155,7 @@
|
|||||||
'outcome' => 'succeeded',
|
'outcome' => 'succeeded',
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
$component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']);
|
$component->call('startBootstrap', ['inventory.sync', 'compliance.snapshot']);
|
||||||
|
|
||||||
expect(OperationRun::query()
|
expect(OperationRun::query()
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
|||||||
@ -197,3 +197,38 @@
|
|||||||
|
|
||||||
expect($drafts->modelKeys())->toBe([(int) $unlinkedDraft->getKey()]);
|
expect($drafts->modelKeys())->toBe([(int) $unlinkedDraft->getKey()]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resolves drafts that still contain legacy bootstrap operation state for read-side normalization', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$tenant = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'status' => Tenant::STATUS_ONBOARDING,
|
||||||
|
]);
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
createUserWithTenant(
|
||||||
|
tenant: $tenant,
|
||||||
|
user: $user,
|
||||||
|
role: 'owner',
|
||||||
|
workspaceRole: 'owner',
|
||||||
|
ensureDefaultMicrosoftProviderConnection: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$draft = createOnboardingDraft([
|
||||||
|
'workspace' => $workspace,
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'state' => [
|
||||||
|
'bootstrap_operation_types' => ['inventory_sync', 'compliance.snapshot'],
|
||||||
|
'bootstrap_operation_runs' => ['inventory_sync' => 12345],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
|
$resolved = app(OnboardingDraftResolver::class)->resolve($draft->getKey(), $user, $workspace);
|
||||||
|
|
||||||
|
expect($resolved->state['bootstrap_operation_types'] ?? null)->toBe(['inventory_sync', 'compliance.snapshot'])
|
||||||
|
->and($resolved->state['bootstrap_operation_runs'] ?? null)->toBe(['inventory_sync' => 12345]);
|
||||||
|
});
|
||||||
|
|||||||
@ -43,7 +43,7 @@
|
|||||||
'execution_prerequisites' => 'not_applicable',
|
'execution_prerequisites' => 'not_applicable',
|
||||||
])
|
])
|
||||||
->and($decision->toArray())->toMatchArray([
|
->and($decision->toArray())->toMatchArray([
|
||||||
'operation_type' => 'inventory_sync',
|
'operation_type' => 'inventory.sync',
|
||||||
'authority_mode' => 'actor_bound',
|
'authority_mode' => 'actor_bound',
|
||||||
'allowed' => true,
|
'allowed' => true,
|
||||||
'retryable' => false,
|
'retryable' => false,
|
||||||
|
|||||||
@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Services\Providers\ProviderOperationRegistry;
|
||||||
|
use App\Support\OperationCatalog;
|
||||||
|
|
||||||
|
it('declares provider operation definitions with canonical operation types only', function (): void {
|
||||||
|
$registry = app(ProviderOperationRegistry::class);
|
||||||
|
|
||||||
|
foreach ($registry->definitions() as $key => $definition) {
|
||||||
|
$operationType = (string) ($definition['operation_type'] ?? '');
|
||||||
|
$resolution = OperationCatalog::resolve($operationType);
|
||||||
|
|
||||||
|
expect($key)->toBe($operationType)
|
||||||
|
->and($resolution->aliasStatus)->toBe('canonical')
|
||||||
|
->and($resolution->canonical->canonicalCode)->toBe($operationType);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('declares provider bindings with canonical operation types only', function (): void {
|
||||||
|
$registry = app(ProviderOperationRegistry::class);
|
||||||
|
|
||||||
|
foreach ($registry->providerBindings() as $key => $bindings) {
|
||||||
|
expect(OperationCatalog::resolve($key)->aliasStatus)->toBe('canonical');
|
||||||
|
|
||||||
|
foreach ($bindings as $binding) {
|
||||||
|
$operationType = (string) ($binding['operation_type'] ?? '');
|
||||||
|
$resolution = OperationCatalog::resolve($operationType);
|
||||||
|
|
||||||
|
expect($operationType)->toBe($key)
|
||||||
|
->and($resolution->aliasStatus)->toBe('canonical')
|
||||||
|
->and($resolution->canonical->canonicalCode)->toBe($operationType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects legacy aliases as provider registry keys', function (): void {
|
||||||
|
$registry = app(ProviderOperationRegistry::class);
|
||||||
|
|
||||||
|
expect($registry->isAllowed('inventory_sync'))->toBeFalse()
|
||||||
|
->and($registry->isAllowed('entra_group_sync'))->toBeFalse()
|
||||||
|
->and($registry->isAllowed('directory_role_definitions.sync'))->toBeFalse()
|
||||||
|
->and($registry->isAllowed('inventory.sync'))->toBeTrue()
|
||||||
|
->and($registry->isAllowed('directory.groups.sync'))->toBeTrue()
|
||||||
|
->and($registry->isAllowed('directory.role_definitions.sync'))->toBeTrue();
|
||||||
|
});
|
||||||
@ -110,7 +110,7 @@
|
|||||||
|
|
||||||
$blocking = OperationRun::factory()->create([
|
$blocking = OperationRun::factory()->create([
|
||||||
'tenant_id' => $tenant->getKey(),
|
'tenant_id' => $tenant->getKey(),
|
||||||
'type' => 'inventory_sync',
|
'type' => 'inventory.sync',
|
||||||
'status' => 'queued',
|
'status' => 'queued',
|
||||||
'context' => [
|
'context' => [
|
||||||
'provider_connection_id' => (int) $connection->getKey(),
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
@ -221,11 +221,11 @@
|
|||||||
$result = $gate->start(
|
$result = $gate->start(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
connection: $connection,
|
connection: $connection,
|
||||||
operationType: 'entra_group_sync',
|
operationType: 'directory.groups.sync',
|
||||||
dispatcher: function (OperationRun $run) use (&$dispatched): void {
|
dispatcher: function (OperationRun $run) use (&$dispatched): void {
|
||||||
$dispatched++;
|
$dispatched++;
|
||||||
|
|
||||||
expect($run->type)->toBe('entra_group_sync');
|
expect($run->type)->toBe('directory.groups.sync');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -257,7 +257,7 @@
|
|||||||
|
|
||||||
$blocking = OperationRun::factory()->create([
|
$blocking = OperationRun::factory()->create([
|
||||||
'tenant_id' => $tenant->getKey(),
|
'tenant_id' => $tenant->getKey(),
|
||||||
'type' => 'inventory_sync',
|
'type' => 'inventory.sync',
|
||||||
'status' => 'running',
|
'status' => 'running',
|
||||||
'context' => [
|
'context' => [
|
||||||
'provider_connection_id' => (int) $connection->getKey(),
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
@ -281,6 +281,28 @@
|
|||||||
expect($result->run->getKey())->toBe($blocking->getKey());
|
expect($result->run->getKey())->toBe($blocking->getKey());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects legacy aliases before starting provider operations', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => 'directory-entra-tenant-id',
|
||||||
|
'consent_status' => 'granted',
|
||||||
|
]);
|
||||||
|
ProviderCredential::factory()->create([
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$gate = app(ProviderOperationStartGate::class);
|
||||||
|
|
||||||
|
expect(fn () => $gate->start(
|
||||||
|
tenant: $tenant,
|
||||||
|
connection: $connection,
|
||||||
|
operationType: 'entra_group_sync',
|
||||||
|
dispatcher: fn () => null,
|
||||||
|
))->toThrow(\InvalidArgumentException::class);
|
||||||
|
});
|
||||||
|
|
||||||
it('blocks provider starts when no explicit provider binding supports the connection provider', function (): void {
|
it('blocks provider starts when no explicit provider binding supports the connection provider', function (): void {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
|
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
|
||||||
|
|||||||
@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Support\OperationCatalog;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
|
|
||||||
|
it('uses canonical dotted operation codes as enum values', function (): void {
|
||||||
|
expect(OperationRunType::BaselineCapture->value)->toBe('baseline.capture')
|
||||||
|
->and(OperationRunType::BaselineCompare->value)->toBe('baseline.compare')
|
||||||
|
->and(OperationRunType::InventorySync->value)->toBe('inventory.sync')
|
||||||
|
->and(OperationRunType::DirectoryGroupsSync->value)->toBe('directory.groups.sync')
|
||||||
|
->and(OperationRunType::BackupScheduleExecute->value)->toBe('backup.schedule.execute')
|
||||||
|
->and(OperationRunType::BackupScheduleRetention->value)->toBe('backup.schedule.retention')
|
||||||
|
->and(OperationRunType::BackupSchedulePurge->value)->toBe('backup.schedule.purge')
|
||||||
|
->and(OperationRunType::DirectoryRoleDefinitionsSync->value)->toBe('directory.role_definitions.sync');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps enum canonicalCode as a no-op compatibility shim', function (): void {
|
||||||
|
foreach (OperationRunType::cases() as $case) {
|
||||||
|
expect($case->canonicalCode())->toBe($case->value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not expose legacy raw aliases as enum values', function (): void {
|
||||||
|
expect(OperationRunType::values())->not->toContain(
|
||||||
|
'baseline_capture',
|
||||||
|
'baseline_compare',
|
||||||
|
'inventory_sync',
|
||||||
|
'entra_group_sync',
|
||||||
|
'backup_schedule_run',
|
||||||
|
'backup_schedule_retention',
|
||||||
|
'backup_schedule_purge',
|
||||||
|
'directory_role_definitions.sync',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps every enum value in the operation catalog as current canonical truth', function (): void {
|
||||||
|
foreach (OperationRunType::values() as $type) {
|
||||||
|
$resolution = OperationCatalog::resolve($type);
|
||||||
|
|
||||||
|
expect($resolution->aliasStatus)->toBe('canonical')
|
||||||
|
->and($resolution->canonical->canonicalCode)->toBe($type);
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -43,6 +43,29 @@
|
|||||||
->and(OperationRunType::BackupSetUpdate->canonicalCode())->toBe('backup_set.update');
|
->and(OperationRunType::BackupSetUpdate->canonicalCode())->toBe('backup_set.update');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps legacy aliases as read-side compatibility only', function (): void {
|
||||||
|
$aliasInventory = OperationCatalog::aliasInventory();
|
||||||
|
|
||||||
|
foreach ($aliasInventory as $rawValue => $metadata) {
|
||||||
|
if (($metadata['alias_status'] ?? null) !== 'legacy_alias') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($metadata['write_allowed'] ?? null)
|
||||||
|
->toBeFalse("Legacy alias [{$rawValue}] must not be write-time truth.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps unknown operation values explicitly unknown', function (): void {
|
||||||
|
$resolution = OperationCatalog::resolve('inventory-sync');
|
||||||
|
|
||||||
|
expect($resolution->canonical->canonicalCode)->toBe('inventory-sync')
|
||||||
|
->and($resolution->canonical->displayLabel)->toBe('Unknown operation')
|
||||||
|
->and($resolution->aliasStatus)->toBe('unknown')
|
||||||
|
->and($resolution->wasLegacyAlias)->toBeFalse()
|
||||||
|
->and($resolution->aliasesConsidered)->toBe([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('publishes contributor-facing operation inventories and alias retirement metadata', function (): void {
|
it('publishes contributor-facing operation inventories and alias retirement metadata', function (): void {
|
||||||
$descriptor = OperationCatalog::ownershipDescriptor();
|
$descriptor = OperationCatalog::ownershipDescriptor();
|
||||||
$canonicalInventory = OperationCatalog::canonicalInventory();
|
$canonicalInventory = OperationCatalog::canonicalInventory();
|
||||||
@ -53,4 +76,4 @@
|
|||||||
->and($canonicalInventory['baseline.compare']['display_label'] ?? null)->toBe('Baseline compare')
|
->and($canonicalInventory['baseline.compare']['display_label'] ?? null)->toBe('Baseline compare')
|
||||||
->and($aliasInventory['baseline_compare']['canonical_name'] ?? null)->toBe('baseline.compare')
|
->and($aliasInventory['baseline_compare']['canonical_name'] ?? null)->toBe('baseline.compare')
|
||||||
->and($aliasInventory['baseline_compare']['retirement_path'] ?? null)->toContain('baseline.compare');
|
->and($aliasInventory['baseline_compare']['retirement_path'] ?? null)->toContain('baseline.compare');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -66,6 +66,20 @@ ### R1.9 Platform Localization v1 (DE/EN)
|
|||||||
|
|
||||||
**Active specs**: — (not yet specced)
|
**Active specs**: — (not yet specced)
|
||||||
|
|
||||||
|
### Product Scalability & Self-Service Foundation
|
||||||
|
Self-service and supportability foundation that keeps TenantPilot operable as a low-headcount, AI-assisted SaaS instead of drifting into manual onboarding, manual support, and founder-dependent customer operations.
|
||||||
|
**Goal**: Productize the recurring work around onboarding, diagnostics, support context, plan limits, and customer guidance so that new customers can evaluate, onboard, operate, and request help with minimal manual intervention.
|
||||||
|
|
||||||
|
- Self-Service Tenant Onboarding & Connection Readiness: guided tenant setup, consent readiness, provider connection checks, permission diagnostics, setup progress, completion score, and concrete next actions
|
||||||
|
- Support Diagnostic Pack: diagnostic bundles for workspace, tenant, OperationRun, Finding, ProviderConnection, and report contexts with relevant health state, permissions, run context, errors, audit references, and AI-readable summaries
|
||||||
|
- In-App Support Request with Context: support entry points that attach workspace, tenant, run/finding/report references, severity, diagnostic pack reference, and ticket reference back into TenantPilot
|
||||||
|
- Product Knowledge & Contextual Help: help registry for feature explanations, status meanings, error guidance, permission rationale, troubleshooting hints, and docs links; also usable as the source layer for later AI support
|
||||||
|
- Plans, Entitlements & Billing Readiness: plan model, feature gates, tenant/workspace/user/report/export/retention limits, trial state, grace periods, billing status, and audited plan changes
|
||||||
|
- Demo & Trial Readiness: seeded demo workspaces, sample tenants, sample baselines/findings/reports, demo reset support, trial provisioning checklist, and sample-data mode where appropriate
|
||||||
|
- Customer-facing transparency hooks: product surfaces should be designed so customer read-only views, review workspaces, support requests, and review-pack downloads can reuse the same underlying entities instead of becoming parallel one-off features
|
||||||
|
|
||||||
|
**Active specs**: — (not yet specced)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Planned (Next Quarter)
|
## Planned (Next Quarter)
|
||||||
@ -99,6 +113,9 @@ ### R2 Completion — Evidence & Exception Workflows
|
|||||||
- Formal evidence/review-pack entity foundation → Spec 153 (evidence snapshots, draft) + Spec 155 (tenant review layer / review packs, draft)
|
- Formal evidence/review-pack entity foundation → Spec 153 (evidence snapshots, draft) + Spec 155 (tenant review layer / review packs, draft)
|
||||||
- Workspace-level PII override for review packs → deferred from 109
|
- Workspace-level PII override for review packs → deferred from 109
|
||||||
- Customer Review Workspace / Read-only View v1 → sharpen customer-facing review consumption: baseline status, latest reviews, findings, accepted risks, evidence/review-pack downloads, customer-safe redaction, and no admin/remediation actions
|
- Customer Review Workspace / Read-only View v1 → sharpen customer-facing review consumption: baseline status, latest reviews, findings, accepted risks, evidence/review-pack downloads, customer-safe redaction, and no admin/remediation actions
|
||||||
|
- Support Diagnostic Pack → connect tenant/review/finding/report/operation contexts into a reusable support bundle before support demand scales
|
||||||
|
- In-App Support Request with Context → attach the relevant diagnostic pack and ticket reference to support workflows without creating a separate support data model
|
||||||
|
- Product Knowledge & Contextual Help → reuse canonical glossary, outcome/reason semantics, and report/finding terminology as the product-help source layer
|
||||||
|
|
||||||
### Findings Workflow v2 / Execution Layer
|
### Findings Workflow v2 / Execution Layer
|
||||||
Turn findings from a reviewable register into an accountable operating flow with clear ownership, personal queues, intake, hygiene, and minimal escalation.
|
Turn findings from a reviewable register into an accountable operating flow with clear ownership, personal queues, intake, hygiene, and minimal escalation.
|
||||||
@ -119,10 +136,57 @@ ### Platform Operations Maturity
|
|||||||
- Raw error/context drilldowns for system console (deferred from Spec 114)
|
- Raw error/context drilldowns for system console (deferred from Spec 114)
|
||||||
- Multi-workspace operator selection in `/system` (deferred from Spec 113)
|
- Multi-workspace operator selection in `/system` (deferred from Spec 113)
|
||||||
|
|
||||||
|
### Solo-Founder SaaS Automation & Operating Readiness
|
||||||
|
Internal operating-system track for running TenantPilot as a lean, AI-assisted SaaS company with repeatable customer acquisition, onboarding, support, billing, security review, and release communication.
|
||||||
|
**Goal**: Keep company operations from becoming founder-only manual work while the product moves from pilots to repeatable customer delivery.
|
||||||
|
|
||||||
|
- Lead Capture & CRM Pipeline: website lead forms, demo requests, waitlist, lead status, pilot status, customer status, follow-up reminders, and meeting notes capture
|
||||||
|
- Demo Environment Automation: repeatable demo workspace, sample tenant data, reset flow, demo scripts, and separate demo stories for MSP and enterprise IT buyers
|
||||||
|
- Trial Provisioning Workflow: trial request intake, plan/limit assignment, provisioning checklist, onboarding status, trial expiry, conversion path, and grace handling
|
||||||
|
- Billing & Contract Readiness: plan matrix, offer templates, invoicing flow, payment/billing status, trial-to-paid process, cancellation process, and grace-period handling
|
||||||
|
- AVV / DPA / TOM / Legal Pack: reusable customer-facing legal and data-processing artifacts aligned with the actual product data model and hosting setup
|
||||||
|
- Security Trust Pack Light: hosting overview, data categories, least-privilege permission model, RBAC model, retention, backup, audit logging, subprocessors, and “what we do not store” documentation
|
||||||
|
- Support Desk + AI Triage: support mailbox or ticket system, categories, priorities, macros, known issues, AI triage, answer drafts, and linkage to TenantPilot diagnostic packs
|
||||||
|
- Knowledge Base Pipeline: public docs, onboarding docs, troubleshooting docs, internal runbooks, and a maintained source set for AI-assisted support
|
||||||
|
- Monitoring & Incident Runbooks: uptime, queues, failed jobs, error tracking, backups, storage, certificates, Graph failure rates, status page, incident templates, postmortem templates, and customer communication templates
|
||||||
|
- Release & Customer Communication Automation: customer changelog, release notes, support notes, migration notes, breaking-change markers, known limitations, and docs-update checklist
|
||||||
|
|
||||||
|
**Active specs**: — (not yet specced; company-ops track, not all items need product specs)
|
||||||
|
|
||||||
|
### Additional Solo-Founder Scale Guardrails
|
||||||
|
Cross-cutting operating guardrails that prevent TenantPilot from scaling through hidden manual work, unclear customer health, missing operational controls, ad-hoc customer communication, or unmanaged founder dependency.
|
||||||
|
**Goal**: Make repeatability, observability, controllability, and delegability explicit before customer volume makes the gaps expensive.
|
||||||
|
|
||||||
|
- Product Usage & Adoption Telemetry: privacy-aware usage signals for onboarding completion, feature adoption, report exports, failed flows, support-triggering surfaces, inactive customers, and trial conversion indicators
|
||||||
|
- Customer Health Score: derived customer/workspace health indicators from login/activity, provider health, last sync, baseline compare freshness, open high findings, overdue SLAs, expiring risk acceptances, failed runs, support load, and review-pack readiness
|
||||||
|
- Operational Controls & Feature Flags: global/workspace kill switches and scoped controls for risky features, restore actions, exports, AI functions, provider actions, trials, maintenance scenarios, and temporary read-only states
|
||||||
|
- Customer Lifecycle Communication: structured lifecycle messages for welcome, onboarding, trial reminders, provider health warnings, review-pack readiness, risk-expiry reminders, release updates, incidents, renewal, payment issues, and churn feedback
|
||||||
|
- Vendor Questionnaire Answer Bank: reusable security/procurement answers aligned with the Security Trust Pack, product data model, Microsoft permissions, hosting, AI usage, subprocessors, retention, backup, deletion, and incident handling
|
||||||
|
- Product Intake & No-Customization Governance: feature-request intake, roadmap-fit classification, no-custom-work policy, customer exception handling, productization rules, and a clear path from request → candidate → spec → release or rejection
|
||||||
|
- Support Severity Matrix & Runbooks: P1–P4 definitions, incident vs support vs bug vs feature request distinction, response expectations by plan, escalation rules, known-issue handling, and internal support runbooks
|
||||||
|
- Data Retention, Export & Deletion Self-Service: customer-facing and operator-facing flows for export, archive, deletion request handling, trial data expiry, workspace deactivation, and evidence/report retention visibility
|
||||||
|
- Business Continuity / Founder Backup Plan: access documentation, secret management, emergency contacts, deployment and restore runbooks, incident templates, DNS/domain/hosting ownership, billing access, and vacation/sickness fallback
|
||||||
|
|
||||||
|
**Active specs**: — (not yet specced; guardrail track, only product-impacting items should become specs)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Mid-term (2–3 Quarters)
|
## Mid-term (2–3 Quarters)
|
||||||
|
|
||||||
|
### Product Usage, Customer Health & Operational Controls
|
||||||
|
Product-side implementation lane for the highest-impact solo-founder guardrails: adoption telemetry, customer/workspace health scoring, and operator controls/feature flags.
|
||||||
|
**Goal**: Give the founder/operator a measurable, controllable view of customer adoption, risk, and operational safety without relying on manual checks across logs, support tools, billing tools, and product screens.
|
||||||
|
**Why it matters**: Low-headcount SaaS only works if the product shows where customers are stuck, which workspaces are unhealthy, and which features can be safely paused or limited during incidents.
|
||||||
|
**Depends on**: Self-Service Tenant Onboarding & Connection Readiness, Support Diagnostic Pack, Plans / Entitlements & Billing Readiness, ProviderConnection health, OperationRun truth, Findings workflow, StoredReports / EvidenceItems, and audit log foundation.
|
||||||
|
**Scope direction**: Start with privacy-aware product telemetry, derived customer/workspace health indicators, and a minimal operational controls registry. Avoid building a full analytics platform, CRM, or customer-success suite in the first slice.
|
||||||
|
|
||||||
|
### AI-Assisted Customer Operations
|
||||||
|
AI-assisted customer operations layer for support, reviews, summaries, release communication, and customer-facing explanations, explicitly bounded by human approval and product auditability.
|
||||||
|
**Goal**: Use AI to prepare, summarize, classify, and draft customer operations work while keeping tenant-changing actions, customer commitments, legal statements, and external communications under human approval.
|
||||||
|
**Why it matters**: TenantPilot can stay lean only if support, customer reviews, release communication, and diagnostics are structured enough for AI assistance without becoming ungoverned automation.
|
||||||
|
**Depends on**: Product Knowledge & Contextual Help, Support Diagnostic Pack, In-App Support Request, StoredReports / EvidenceItems, Findings workflow maturity, release-note discipline, and company support-desk structure.
|
||||||
|
**Scope direction**: Start with AI-generated support summaries, finding explanations, tenant review summaries, diagnostic summaries, release-note drafts, and support-response drafts. Avoid autonomous tenant remediation, automatic risk acceptance, automatic legal commitments, or customer-facing messages without review.
|
||||||
|
|
||||||
### Decision-Based Operating Foundations
|
### Decision-Based Operating Foundations
|
||||||
Constitution hardening for decision-first governance, workflow-first navigation, surface taxonomy, and primary-vs-evidence surface refactoring.
|
Constitution hardening for decision-first governance, workflow-first navigation, surface taxonomy, and primary-vs-evidence surface refactoring.
|
||||||
**Goal**: Prepare TenantPilot for a quieter, decision-centered operating model where primary surfaces ask for action and deeper technical detail stays available on demand.
|
**Goal**: Prepare TenantPilot for a quieter, decision-centered operating model where primary surfaces ask for action and deeper technical detail stays available on demand.
|
||||||
@ -136,12 +200,24 @@ ### MSP Portfolio & Operations (Multi-Tenant)
|
|||||||
**Prerequisites**: Decision-Based Operating Foundations, Cross-tenant compare (Spec 043 — draft only).
|
**Prerequisites**: Decision-Based Operating Foundations, Cross-tenant compare (Spec 043 — draft only).
|
||||||
**Later expansion**: portfolio operations should eventually include a cross-tenant findings workboard once tenant-level inbox and intake flows are stable.
|
**Later expansion**: portfolio operations should eventually include a cross-tenant findings workboard once tenant-level inbox and intake flows are stable.
|
||||||
|
|
||||||
### Human-in-the-Loop Autonomous Governance (Decision-Based Operating)
|
### Human-in-the-Loop Autonomous Governance (Microsoft-first, Provider-extensible Decision-Based Operating)
|
||||||
Continuous detection, triage, decision drafting, approval-driven execution, and closed-loop evidence for governance actions across the workspace portfolio.
|
Continuous detection, triage, decision drafting, approval-driven execution, and closed-loop evidence for governance actions across the Microsoft-first workspace portfolio, while keeping the decision model provider-extensible for later non-Microsoft domains.
|
||||||
**Goal**: Reduce operator work from searching and correlating to approving, rejecting, deferring, or time-boxing deviations while TenantPilot handles the mechanical follow-through.
|
**Goal**: Move TenantPilot from Microsoft tenant search-and-troubleshoot workflows to decision-based governance operations. Customers and operators should not have to click through tenants, runs, findings, reports, logs, and evidence to discover what needs attention. TenantPilot should detect relevant work, gather context, summarize impact, propose safe next actions, and present decision-ready items. The first product scope stays Microsoft tenant governance; the underlying decision model should avoid hard-coding Microsoft-only assumptions where a provider-neutral abstraction is already available.
|
||||||
**Why it matters**: This is the longer-term MSP Portfolio OS layer. TenantPilot becomes the decision control plane for accountable governance, not just a browser for runs, evidence, and tenant state.
|
**Why it matters**: This is the longer-term MSP Portfolio OS layer. TenantPilot becomes the decision control plane for accountable Microsoft tenant governance first, not just a browser for runs, evidence, and tenant state. Detail pages remain available as evidence and diagnostics, but the default operating model becomes guided decisions, not manual investigation.
|
||||||
**Depends on**: Decision-Based Operating Foundations, MSP Portfolio & Operations surfaces, drift/findings/exception maturity, actionable alerts with structured payloads, canonical operation/evidence truth.
|
**Depends on**: Decision-Based Operating Foundations, Product Knowledge & Contextual Help, Support Diagnostic Pack, Customer Health Score, MSP Portfolio & Operations surfaces, drift/findings/exception maturity, actionable alerts with structured payloads, canonical operation/evidence truth, operational controls, and human approval gates.
|
||||||
**Scope direction**: Start with governance inbox + decision packs + actionable alerts. Later add automation policies, guardrails, maintenance windows, dual approval, and before/after evidence automation. Keep human approval and auditability central; avoid blind autopilot remediation.
|
**Scope direction**: Start with a Governance Inbox / Action Center, decision items, decision packs, actionable alerts, and approval-gated workflows for Microsoft tenant governance. Later add automation policies, guardrails, maintenance windows, dual approval, before/after evidence automation, and limited remediation execution. Keep human approval and auditability central; avoid blind autopilot remediation.
|
||||||
|
|
||||||
|
**Core workflow**:
|
||||||
|
- Detect relevant governance work automatically
|
||||||
|
- Group, deduplicate, and prioritize related signals
|
||||||
|
- Generate a decision pack with summary, impact, evidence, affected tenants/policies, recommended actions, and confidence
|
||||||
|
- Present clear actions such as approve, reject, snooze, assign, accept risk, create ticket, run compare, generate review pack, or request evidence
|
||||||
|
- Require human approval for tenant-changing, customer-facing, or risk-accepting actions
|
||||||
|
- Execute approved follow-up through OperationRuns or controlled workflows
|
||||||
|
- Verify outcome and attach before/after evidence
|
||||||
|
- Keep audit trail across detection, recommendation, approval, execution, and verification
|
||||||
|
|
||||||
|
**Anti-pattern**: Do not make customers manually troubleshoot by navigating through raw runs, logs, tables, and details as the primary workflow. Raw surfaces are evidence and diagnostics, not the main operating model.
|
||||||
|
|
||||||
### Drift & Change Governance ("Revenue Lever #1")
|
### Drift & Change Governance ("Revenue Lever #1")
|
||||||
Change approval workflows (DEV→PROD with audit pack), guardrails/policy freeze windows, tamper detection.
|
Change approval workflows (DEV→PROD with audit pack), guardrails/policy freeze windows, tamper detection.
|
||||||
@ -226,6 +302,15 @@ ## Infrastructure & Platform Debt
|
|||||||
|
|
||||||
| Item | Risk | Status |
|
| Item | Risk | Status |
|
||||||
|------|------|--------|
|
|------|------|--------|
|
||||||
|
| No explicit company automation roadmap linkage | Risk that sales, support, billing, legal, and customer communication become founder-only manual work | Covered by Solo-Founder SaaS Automation & Operating Readiness |
|
||||||
|
| No product-level entitlement foundation yet | Later pricing, trial, retention, export, user, and tenant limits may require invasive retrofits | Covered by Product Scalability & Self-Service Foundation |
|
||||||
|
| No structured support diagnostic bundle yet | Support cases require manual context gathering across tenants, runs, findings, providers, and reports | Covered by Product Scalability & Self-Service Foundation |
|
||||||
|
| No formal security trust pack yet | Enterprise sales and customer security reviews require repeated manual explanations | Covered by Solo-Founder SaaS Automation & Operating Readiness |
|
||||||
|
| No product usage/adoption telemetry yet | Founder cannot see onboarding drop-off, feature adoption, trial health, or support-triggering surfaces without manual investigation | Covered by Additional Solo-Founder Scale Guardrails |
|
||||||
|
| No customer health score yet | Churn, inactive customers, stale reviews, unhealthy provider connections, and unresolved high-risk findings may be noticed too late | Covered by Additional Solo-Founder Scale Guardrails |
|
||||||
|
| No explicit operational controls / feature flags lane | Incidents or risky features may require code changes or manual database intervention instead of safe operator controls | Covered by Additional Solo-Founder Scale Guardrails |
|
||||||
|
| No no-customization governance yet | Customer-specific requests can silently turn the product into consulting work and create hidden maintenance obligations | Covered by Additional Solo-Founder Scale Guardrails |
|
||||||
|
| No business-continuity / founder-backup plan yet | Solo-founder operations create continuity risk for incidents, illness, vacation, access recovery, and customer trust | Covered by Additional Solo-Founder Scale Guardrails |
|
||||||
| No `.env.example` in repo | Onboarding friction | Open |
|
| No `.env.example` in repo | Onboarding friction | Open |
|
||||||
| CI pipeline config status drift | Roadmap debt list may be stale because workflow files exist and should be re-audited; align with static-analysis and architecture-boundary gates | Review needed |
|
| CI pipeline config status drift | Roadmap debt list may be stale because workflow files exist and should be re-audited; align with static-analysis and architecture-boundary gates | Review needed |
|
||||||
| No PHPStan/Larastan | No static analysis; covered by `Static Analysis Baseline for Platform Code` spec candidate | Open |
|
| No PHPStan/Larastan | No static analysis; covered by `Static Analysis Baseline for Platform Code` spec candidate | Open |
|
||||||
@ -238,17 +323,26 @@ ## Infrastructure & Platform Debt
|
|||||||
|
|
||||||
## Priority Ranking (from Product Brainstorming)
|
## Priority Ranking (from Product Brainstorming)
|
||||||
|
|
||||||
1. MSP Portfolio + Alerting
|
1. Product Scalability & Self-Service Foundation
|
||||||
2. Drift + Approval Workflows
|
2. Product Usage, Customer Health & Operational Controls
|
||||||
3. Standardization / Linting
|
3. Decision-Based Operating / Governance Inbox
|
||||||
4. Promotion DEV→PROD
|
4. MSP Portfolio + Alerting
|
||||||
5. Recovery Confidence
|
5. Drift + Approval Workflows
|
||||||
|
6. Evidence / Review Packs + Customer Review Workspace
|
||||||
|
7. Standardization / Linting
|
||||||
|
8. Promotion DEV→PROD
|
||||||
|
9. Recovery Confidence
|
||||||
|
10. Solo-Founder SaaS Automation & Operating Readiness
|
||||||
|
11. Additional Solo-Founder Scale Guardrails
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## How to use this file
|
## How to use this file
|
||||||
|
|
||||||
- **Big themes** live here.
|
- **Big product and operating themes** live here.
|
||||||
- **Concrete spec candidates** → see [spec-candidates.md](spec-candidates.md)
|
- **Concrete spec candidates** → see [spec-candidates.md](spec-candidates.md)
|
||||||
|
- **Company automation / solo-founder operating items** live here as strategic tracks first; only product-impacting or repeatable engineering work should become spec candidates.
|
||||||
|
- **Solo-founder guardrails** should remain visible even when they are not immediate product specs, because they define what must become measurable, controllable, delegable, or documented before customer volume grows.
|
||||||
|
- **Governance positioning is Microsoft-first, provider-extensible**: roadmap language should keep the initial product scope focused on Microsoft tenant governance while avoiding unnecessary Microsoft-only coupling in platform-level abstractions.
|
||||||
- **Small discoveries from implementation** → see [discoveries.md](discoveries.md)
|
- **Small discoveries from implementation** → see [discoveries.md](discoveries.md)
|
||||||
- **Product principles** → see [principles.md](principles.md)
|
- **Product principles** → see [principles.md](principles.md)
|
||||||
|
|||||||
@ -5,7 +5,7 @@ # Spec Candidates
|
|||||||
>
|
>
|
||||||
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
||||||
|
|
||||||
> **Last reviewed**: 2026-04-25 (added Codebase Quality & Engineering Maturity cluster from full codebase audit with System Panel Least-Privilege Capability Model, Static Analysis Baseline, Architecture Boundary Guard Tests, Filament Hotspot Decomposition Foundation, and RestoreService Responsibility Split; retained OperationRun UX Consistency and Provider Boundary hardening sequences as current strategic hardening lanes)
|
> **Last reviewed**: 2026-04-25 (added Product Scalability & Self-Service Foundation candidates and Additional Solo-Founder Scale Guardrails candidates from roadmap: Product Usage & Adoption Telemetry, Customer Health Score, Operational Controls & Feature Flags, Customer Lifecycle Communication, Product Intake & No-Customization Governance, and Data Retention / Export / Deletion Self-Service; retained Codebase Quality & Engineering Maturity cluster and existing strategic hardening lanes)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -24,6 +24,15 @@ ## Inbox
|
|||||||
- Workspace-level PII override for review packs (deferred from Spec 109 — controls whether PII is included/redacted in tenant review pack exports at workspace scope)
|
- Workspace-level PII override for review packs (deferred from Spec 109 — controls whether PII is included/redacted in tenant review pack exports at workspace scope)
|
||||||
- CSV export for filtered run metadata (deferred from Spec 114 — allow operators to export filtered operation run lists from the system console as CSV)
|
- CSV export for filtered run metadata (deferred from Spec 114 — allow operators to export filtered operation run lists from the system console as CSV)
|
||||||
- Raw error/context drilldowns for system console (deferred from Spec 114 — in-product drilldown into raw error payloads and execution context for failed/stuck runs in the system console)
|
- Raw error/context drilldowns for system console (deferred from Spec 114 — in-product drilldown into raw error payloads and execution context for failed/stuck runs in the system console)
|
||||||
|
- Lead Capture & CRM Pipeline (company-ops track; not a product spec unless TenantPilot later needs in-product customer lifecycle surfaces)
|
||||||
|
- Billing & Contract Readiness (company-ops track; product spec only for plan/entitlement/billing-status foundation)
|
||||||
|
- AVV / DPA / TOM / Legal Pack (company-ops track; source artifacts should align with product data model but are not a product feature by default)
|
||||||
|
- Support Desk + AI Triage (company-ops track; product spec only where TenantPilot creates support context bundles or in-app support requests)
|
||||||
|
- Monitoring & Incident Runbooks (company-ops track; product spec only where platform telemetry or customer-facing status integrations are required)
|
||||||
|
- Release & Customer Communication Automation (company-ops track; product spec only where release metadata/changelog becomes in-product)
|
||||||
|
- Vendor Questionnaire Answer Bank (company-ops track; generally not a product spec unless answers become customer-facing trust-center content or product-backed compliance evidence)
|
||||||
|
- Support Severity Matrix & Runbooks (company-ops track; product spec only where severity, SLA, escalation, or incident state becomes modeled in TenantPilot)
|
||||||
|
- Business Continuity / Founder Backup Plan (company-ops track; not a product spec unless product-side operational controls or customer-facing continuity surfaces are required)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -64,21 +73,569 @@ ## Qualified
|
|||||||
|
|
||||||
> Problem + Nutzen klar. Scope noch offen. Braucht noch Priorisierung.
|
> Problem + Nutzen klar. Scope noch offen. Braucht noch Priorisierung.
|
||||||
|
|
||||||
|
|
||||||
> **Current strategic priority — Governance Platform Foundation**
|
> **Current strategic priority — Governance Platform Foundation**
|
||||||
>
|
>
|
||||||
> The next promoted specs should stabilize TenantPilot as a Governance-of-Record platform before expanding into additional Microsoft domains, compliance overlays, or multi-cloud execution.
|
> The next promoted specs should stabilize TenantPilot as a Governance-of-Record platform before expanding into additional Microsoft domains, compliance overlays, or multi-cloud execution.
|
||||||
>
|
>
|
||||||
> Recommended next sequence:
|
> Recommended next sequence:
|
||||||
>
|
>
|
||||||
> 1. **Provider Identity & Target Scope Neutrality**
|
> 1. **Self-Service Tenant Onboarding & Connection Readiness**
|
||||||
> 2. **Canonical Operation Type Source of Truth**
|
> 2. **Support Diagnostic Pack**
|
||||||
> 3. **Platform Vocabulary Boundary Enforcement for Governed Subject Keys**
|
> 3. **Product Usage & Adoption Telemetry**
|
||||||
> 4. **Customer Review Workspace v1**
|
> 4. **Operational Controls & Feature Flags**
|
||||||
|
> 5. **Provider Identity & Target Scope Neutrality**
|
||||||
|
> 6. **Canonical Operation Type Source of Truth**
|
||||||
|
> 7. **Platform Vocabulary Boundary Enforcement for Governed Subject Keys**
|
||||||
|
> 8. **Customer Review Workspace v1**
|
||||||
>
|
>
|
||||||
> Rationale: the repo already has strong baseline, findings, evidence, review, operation-run, and operator foundations. With Canonical Control Catalog Foundation and Provider Boundary Hardening now specced, the immediate remaining risk is semantic drift in provider identity, operation-type dual semantics, and governed-subject key leakage.
|
> Rationale: the repo already has strong baseline, findings, evidence, review, operation-run, and operator foundations. With Canonical Control Catalog Foundation and Provider Boundary Hardening now specced, the immediate remaining product risk is not only semantic drift in provider identity, operation-type dual semantics, and governed-subject key leakage; it is also founder-dependent onboarding/support and lack of product-side observability/control. Self-service onboarding, diagnostic packs, adoption telemetry, and operational controls therefore move ahead of broader expansion so TenantPilot becomes repeatably operable, measurable, and safe to run with low headcount.
|
||||||
|
|
||||||
|
|
||||||
> Codebase Quality & Engineering Maturity cluster: these candidates come from the full codebase quality audit on 2026-04-25. The audit classified the repo as **good / product-capable, not bad coding**, but identified a small set of structural risks that should be handled before larger feature expansion: coarse System Panel platform visibility, missing static-analysis gates, thin architecture-boundary enforcement, and several large Filament/service hotspots. This cluster is intentionally hardening-focused; it must not become a broad rewrite or cosmetic cleanup campaign.
|
> Product Scalability & Self-Service Foundation cluster: these candidates come from the roadmap update on 2026-04-25. The goal is to keep TenantPilot operable as a low-headcount, AI-assisted SaaS by productizing recurring onboarding, support, diagnostics, entitlement, help, demo, and customer-operations work. This cluster should not become a generic backoffice automation program. Only product-impacting or repeatable engineering work belongs here; pure company-ops work stays in the roadmap / operating system track.
|
||||||
|
|
||||||
|
### Self-Service Tenant Onboarding & Connection Readiness
|
||||||
|
- **Type**: product scalability / onboarding foundation
|
||||||
|
- **Source**: roadmap update 2026-04-25 — Product Scalability & Self-Service Foundation
|
||||||
|
- **Problem**: Tenant onboarding, Microsoft consent readiness, provider connection validation, permission diagnostics, and setup guidance can become founder-led manual work if they are not productized. A customer or MSP should not need a live walkthrough for every tenant connection just to understand what is missing, what is healthy, and what the next action is.
|
||||||
|
- **Why it matters**: TenantPilot cannot scale as a solo-founder or low-headcount SaaS if every pilot, trial, or customer tenant requires manual onboarding support. The product already has ProviderConnection, health, onboarding, operation-run, and permission-related foundations; these need to converge into an operator-facing readiness workflow.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- provide guided tenant setup with clear setup steps and completion state
|
||||||
|
- expose consent readiness and permission diagnostics in product language
|
||||||
|
- show provider connection health and actionable next steps before deeper governance workflows are used
|
||||||
|
- distinguish missing consent, missing permissions, unreachable provider, expired credentials, blocked health checks, and not-yet-run checks
|
||||||
|
- persist or derive an onboarding/readiness status that can be reused by dashboards, support diagnostics, trial flows, and customer review surfaces
|
||||||
|
- keep provider-specific Microsoft details contextual while preserving the provider-boundary language from the platform hardening lane
|
||||||
|
- **Scope boundaries**:
|
||||||
|
- **In scope**: guided onboarding status, readiness checklist, provider connection health summary, permission diagnostics, setup progress, next-action guidance, and tests for readiness semantics
|
||||||
|
- **Out of scope**: full CRM/trial pipeline, billing activation, broad provider marketplace, custom customer-specific onboarding flows, or autonomous tenant remediation
|
||||||
|
- **Acceptance points**:
|
||||||
|
- a new workspace/tenant operator can see which onboarding steps are complete and which are blocking
|
||||||
|
- missing or insufficient Microsoft permissions produce explicit operator guidance rather than generic failure copy
|
||||||
|
- provider connection health is visible without requiring raw run/context inspection
|
||||||
|
- readiness state can be consumed by support diagnostic packs and trial/demo flows
|
||||||
|
- server-side policies still enforce who can view or manage onboarding state
|
||||||
|
- **Risks / open questions**:
|
||||||
|
- Avoid creating a second onboarding model if existing onboarding/session/provider entities can be composed
|
||||||
|
- Readiness must not become a false-green signal; failed or stale health checks need explicit freshness semantics
|
||||||
|
- Provider-specific consent details should not leak into generic platform vocabulary as permanent truth
|
||||||
|
- **Dependencies**: ProviderConnection, managed tenant onboarding workflow, provider health checks, permission/consent diagnostics, OperationRun links, Provider Boundary Hardening
|
||||||
|
- **Related specs / candidates**: Provider Identity & Target Scope Neutrality, Provider Surface Vocabulary & Descriptor Cleanup, Support Diagnostic Pack, Product Knowledge & Contextual Help
|
||||||
|
- **Strategic sequencing**: First item in this product-scalability cluster because it directly reduces manual onboarding and supports trials, demos, support, and customer transparency.
|
||||||
|
- **Priority**: high
|
||||||
|
|
||||||
|
### Support Diagnostic Pack
|
||||||
|
- **Type**: product scalability / supportability foundation
|
||||||
|
- **Source**: roadmap update 2026-04-25 — Product Scalability & Self-Service Foundation
|
||||||
|
- **Problem**: Support cases currently risk requiring manual context gathering across workspace, tenant, ProviderConnection, OperationRun, Finding, Report, Evidence, and audit surfaces. Without a reusable diagnostic bundle, every support request becomes an investigation task before the actual issue can be addressed.
|
||||||
|
- **Why it matters**: A low-headcount SaaS needs support context to be captured by the product, not reconstructed by the founder. Diagnostic packs also create the safe input layer for later AI-assisted support summaries and triage without granting an AI or support user broad ad-hoc access to everything.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- define a support diagnostic bundle contract for workspace, tenant, OperationRun, Finding, ProviderConnection, StoredReport, and review-pack contexts
|
||||||
|
- include relevant health state, latest operation links, failure reason codes, permission/connection state, freshness, artifact references, audit references, and redacted operator summaries
|
||||||
|
- provide an AI-readable but customer-safe summary shape that can be attached to support requests
|
||||||
|
- keep raw sensitive payloads out of the default pack unless explicitly authorized
|
||||||
|
- model redaction and access checks as first-class behavior
|
||||||
|
- allow diagnostic packs to be referenced from in-app support requests and internal support workflows
|
||||||
|
- **Scope boundaries**:
|
||||||
|
- **In scope**: diagnostic pack contract, context collectors, redaction rules, support-safe summary generation, access policy, references to runs/findings/reports/evidence, and tests
|
||||||
|
- **Out of scope**: external ticket-system integration, support desk implementation, AI chat bot, broad log export, customer-visible trust center, or unrestricted raw payload download
|
||||||
|
- **Acceptance points**:
|
||||||
|
- a diagnostic pack can be generated for at least tenant and OperationRun contexts
|
||||||
|
- pack contents are deterministic, scoped, and redacted according to caller capability
|
||||||
|
- the pack links to canonical OperationRun/report/finding/evidence records instead of duplicating truth
|
||||||
|
- sensitive raw provider payloads are excluded by default
|
||||||
|
- tests prove unauthorized users cannot generate packs for unrelated workspaces/tenants
|
||||||
|
- **Risks / open questions**:
|
||||||
|
- Over-including raw context could create data-leak or compliance risk
|
||||||
|
- Under-including context would make the pack less useful and push operators back to manual investigation
|
||||||
|
- The product needs a clear capability boundary, likely related to `platform.support_diagnostics.view` and tenant/workspace support permissions
|
||||||
|
- **Dependencies**: OperationRun link contract, StoredReports / EvidenceItems, Findings workflow, ProviderConnection health, audit log foundation, System Panel least-privilege model
|
||||||
|
- **Related specs / candidates**: In-App Support Request with Context, AI-Assisted Customer Operations, System Panel Least-Privilege Capability Model, OperationRun Start UX Contract
|
||||||
|
- **Strategic sequencing**: Second item after Self-Service Tenant Onboarding; it should land before support volume grows and before AI support triage is introduced.
|
||||||
|
- **Priority**: high
|
||||||
|
|
||||||
|
### In-App Support Request with Context
|
||||||
|
- **Type**: product scalability / support workflow
|
||||||
|
- **Source**: roadmap update 2026-04-25 — Product Scalability & Self-Service Foundation
|
||||||
|
- **Problem**: A generic support email or external ticket link loses the most important product context: workspace, tenant, operation, finding, report, evidence, severity, and current diagnostic state. This creates avoidable back-and-forth and makes support impossible to automate cleanly.
|
||||||
|
- **Why it matters**: If TenantPilot is meant to scale with minimal staff, support requests must be structured at the moment they are created. The product should attach the right context automatically instead of relying on customers to describe technical state manually.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- add context-aware support request entry points on selected high-value surfaces
|
||||||
|
- attach workspace, tenant, OperationRun, Finding, ProviderConnection, StoredReport, or review-pack references automatically
|
||||||
|
- attach or reference a Support Diagnostic Pack when available
|
||||||
|
- capture severity, customer-facing message, optional reproduction notes, and contact metadata
|
||||||
|
- create an internal support reference or external ticket reference when configured
|
||||||
|
- emit an audit event for support request creation where appropriate
|
||||||
|
- **Scope boundaries**:
|
||||||
|
- **In scope**: in-product support request model or outbound adapter seam, context attachment, diagnostic-pack reference, ticket reference field, audit event, capability checks, and first adoption on one or two critical surfaces
|
||||||
|
- **Out of scope**: full helpdesk product, two-way ticket sync, SLA engine, AI support bot, CRM pipeline, or broad customer success automation
|
||||||
|
- **Acceptance points**:
|
||||||
|
- support request created from a run/finding/tenant surface carries the relevant context without manual copy-paste
|
||||||
|
- request creation respects workspace/tenant authorization
|
||||||
|
- diagnostic pack attachment/reference is capability- and redaction-aware
|
||||||
|
- support request status or ticket reference can be shown back in the product where useful
|
||||||
|
- tests prove unrelated tenant context cannot be attached accidentally
|
||||||
|
- **Risks / open questions**:
|
||||||
|
- Decide whether the first version stores support requests in TenantPilot, sends them outbound only, or supports both via an adapter seam
|
||||||
|
- Avoid coupling the product to one helpdesk provider too early
|
||||||
|
- Ensure support request creation does not expose internal-only diagnostic content to customer members
|
||||||
|
- **Dependencies**: Support Diagnostic Pack, audit log foundation, notification/ticket-ref patterns, Customer Review Workspace v1 if customer users can create requests
|
||||||
|
- **Related specs / candidates**: Support Diagnostic Pack, PSA/Ticketing v1, Customer Review Workspace v1, AI-Assisted Customer Operations
|
||||||
|
- **Strategic sequencing**: Third item in this cluster; should follow or minimally depend on the diagnostic-pack contract.
|
||||||
|
- **Priority**: high
|
||||||
|
|
||||||
|
### Product Knowledge & Contextual Help
|
||||||
|
- **Type**: product scalability / operator guidance / support reduction
|
||||||
|
- **Source**: roadmap update 2026-04-25 — Product Scalability & Self-Service Foundation
|
||||||
|
- **Problem**: Statuses, findings, drift states, permission requirements, risk acceptance, evidence gaps, and operation outcomes can require founder explanation if the product does not provide contextual help. Existing glossary and reason-code work creates the vocabulary foundation, but not a structured product-help layer.
|
||||||
|
- **Why it matters**: Every unclear state becomes a support ticket, onboarding call, or sales objection. A product knowledge layer also becomes the maintained source for public docs, support macros, AI support summaries, and customer-facing explanations.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- introduce a contextual help registry keyed by feature, surface, status, reason code, and action where appropriate
|
||||||
|
- reuse canonical glossary and reason-code translation semantics instead of inventing local help copy
|
||||||
|
- provide operator-facing explanations for common states such as drift, limited confidence, risk accepted, evidence gap, blocked run, stale run, missing permission, and connection unhealthy
|
||||||
|
- support docs links, troubleshooting hints, and safe next actions
|
||||||
|
- keep machine/audit/export semantics invariant and avoid localizing core identifiers
|
||||||
|
- make the registry usable by later AI-assisted customer operations as a trusted knowledge source
|
||||||
|
- **Scope boundaries**:
|
||||||
|
- **In scope**: help registry, first high-value surface integrations, glossary/reason-code linkage, docs-link structure, troubleshooting snippets, and tests for missing/invalid keys where useful
|
||||||
|
- **Out of scope**: full public documentation site, AI chatbot, complete localization overhaul, legal/compliance claims, or rewriting every help text in the product
|
||||||
|
- **Acceptance points**:
|
||||||
|
- at least two critical surfaces consume contextual help from the registry instead of local hardcoded explanations
|
||||||
|
- help copy references canonical terminology for findings, baseline, drift, risk acceptance, evidence, and operation outcomes
|
||||||
|
- missing help keys fail predictably or degrade gracefully
|
||||||
|
- the registry can expose a machine-readable source set for future AI support without including secrets or customer data
|
||||||
|
- help content is reviewable and versionable as product knowledge, not scattered UI prose
|
||||||
|
- **Risks / open questions**:
|
||||||
|
- Too much help text can make enterprise UI noisy; progressive disclosure is required
|
||||||
|
- Help registry should not become a second source of truth for status semantics
|
||||||
|
- Localization and terminology governance need a clear boundary with Platform Localization v1
|
||||||
|
- **Dependencies**: Platform Vocabulary Glossary, Operator Reason Code Translation, Governance Friction & Operator Vocabulary Hardening, Platform Localization v1 direction
|
||||||
|
- **Related specs / candidates**: AI-Assisted Customer Operations, Self-Service Tenant Onboarding & Connection Readiness, Support Diagnostic Pack, Baseline Compare Scope Guardrails & Ambiguity Guidance
|
||||||
|
- **Strategic sequencing**: Can run in parallel with support diagnostics, but should land before AI-generated customer explanations.
|
||||||
|
- **Priority**: high
|
||||||
|
|
||||||
|
### Plans, Entitlements & Billing Readiness
|
||||||
|
- **Type**: product architecture / commercial scalability foundation
|
||||||
|
- **Source**: roadmap update 2026-04-25 — Product Scalability & Self-Service Foundation
|
||||||
|
- **Problem**: TenantPilot needs a product-level way to express plan limits, feature gates, trial/grace status, workspace/tenant/user/report/export/retention limits, and billing state before real customer growth. Without an entitlement foundation, pricing and packaging decisions later require invasive retrofits across RBAC, exports, retention, reports, tenant counts, and customer views.
|
||||||
|
- **Why it matters**: A SaaS cannot scale cleanly if commercial packaging is implemented as scattered conditionals or manual founder decisions. Entitlements are not just billing; they are product behavior, support behavior, trial behavior, and customer expectation management.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- introduce plan and entitlement primitives at workspace/account scope
|
||||||
|
- model feature gates and quantitative limits separately
|
||||||
|
- support trial, active, grace, suspended/read-only, and canceled billing states where appropriate
|
||||||
|
- define enforcement points for tenants, users, exports, retention, reports, evidence packs, and advanced governance features
|
||||||
|
- audit plan changes and entitlement overrides
|
||||||
|
- keep external billing-provider integration behind an adapter seam and out of the initial foundation if needed
|
||||||
|
- **Scope boundaries**:
|
||||||
|
- **In scope**: plan model, entitlement model, feature-gate checks, limit checks, trial/grace/billing status, audit events, first enforcement points, and tests
|
||||||
|
- **Out of scope**: full Stripe integration, payment collection UI, invoice rendering, accounting integration, tax automation, custom enterprise contract engine, or public pricing page
|
||||||
|
- **Acceptance points**:
|
||||||
|
- workspace/account has a resolved plan and entitlement set
|
||||||
|
- feature gates and numeric limits can be checked through a central service instead of scattered conditionals
|
||||||
|
- trial and grace states influence product access in a predictable and tested way
|
||||||
|
- plan changes and overrides are audited
|
||||||
|
- at least one real product limit is enforced through the entitlement service
|
||||||
|
- **Risks / open questions**:
|
||||||
|
- Premature pricing complexity could slow product discovery; start with simple plans and explicit overrides
|
||||||
|
- Enterprise contracts may require manual overrides, but those overrides must remain auditable
|
||||||
|
- Read-only/suspended behavior must be carefully designed so customers do not lose access to evidence or audit history unexpectedly
|
||||||
|
- **Dependencies**: workspace/account model, RBAC/capabilities, audit log foundation, retention/export/report features, Customer Review Workspace direction
|
||||||
|
- **Related specs / candidates**: Customer Review Workspace v1, Review Pack export, Evidence domain, Security Trust Pack Light
|
||||||
|
- **Strategic sequencing**: High priority before broader customer onboarding and paid trials, but can be implemented as a foundation slice without full billing integration.
|
||||||
|
- **Priority**: high
|
||||||
|
|
||||||
|
### Demo & Trial Readiness
|
||||||
|
- **Type**: product scalability / sales enablement foundation
|
||||||
|
- **Source**: roadmap update 2026-04-25 — Product Scalability & Self-Service Foundation and Solo-Founder SaaS Automation track
|
||||||
|
- **Problem**: Demos and trials become manual work if the product cannot provide repeatable demo data, resettable demo workspaces, realistic sample baselines/findings/reports, and a clear trial provisioning path. Without this, sales conversations depend on live manual setup or fragile local data.
|
||||||
|
- **Why it matters**: A solo-founder SaaS needs demos and trials to be repeatable. TenantPilot's value is easier to understand when buyers can see baselines, drift, findings, risk acceptance, evidence packs, and reviews without waiting for a real tenant to produce all states naturally.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- create a demo workspace/sample data mode with seeded tenants, baselines, findings, review packs, evidence, and operation history
|
||||||
|
- provide a reset flow or safe reseed process for demo environments
|
||||||
|
- define demo stories for MSP buyers and enterprise IT buyers
|
||||||
|
- create a trial provisioning checklist that ties into onboarding/readiness and plan/entitlement state
|
||||||
|
- keep demo data clearly marked so it never mixes with production customer truth
|
||||||
|
- **Scope boundaries**:
|
||||||
|
- **In scope**: demo seed data, demo reset support, sample governance artifacts, trial readiness checklist, demo-mode indicators, and tests for data separation
|
||||||
|
- **Out of scope**: CRM pipeline, public signup flow, payment collection, marketing website, fully automated self-serve provisioning, or fake provider execution pretending to be real tenant truth
|
||||||
|
- **Acceptance points**:
|
||||||
|
- demo environment can be prepared repeatably without manual database editing
|
||||||
|
- sample data covers at least baseline, drift/finding, risk acceptance, evidence/report, and operation-run stories
|
||||||
|
- demo/sample data is visibly marked and isolated from real customer data
|
||||||
|
- trial readiness can reuse onboarding/readiness and entitlement foundations
|
||||||
|
- reset/reseed process is safe and documented
|
||||||
|
- **Risks / open questions**:
|
||||||
|
- Fake data must not undermine trust by looking like real Microsoft tenant evidence
|
||||||
|
- Demo mode should not introduce shortcuts into production code paths without explicit safeguards
|
||||||
|
- Trial provisioning may later become its own larger spec once real acquisition flow is known
|
||||||
|
- **Dependencies**: StoredReports / EvidenceItems, Findings workflow, Baseline governance, Self-Service Tenant Onboarding, Plans / Entitlements
|
||||||
|
- **Related specs / candidates**: Customer Review Workspace v1, Tenant Review Run, Product Knowledge & Contextual Help
|
||||||
|
- **Strategic sequencing**: Medium-high. It becomes more urgent once first external demos and pilots become frequent.
|
||||||
|
- **Priority**: medium-high
|
||||||
|
|
||||||
|
### Security Trust Pack Light
|
||||||
|
- **Type**: company-ops / product trust enablement
|
||||||
|
- **Source**: roadmap update 2026-04-25 — Solo-Founder SaaS Automation & Operating Readiness
|
||||||
|
- **Problem**: Enterprise buyers will repeatedly ask how TenantPilot handles hosting, data categories, Microsoft permissions, least privilege, RBAC, retention, backups, audit logs, subprocessors, and what is not stored. If these answers remain ad-hoc, sales and onboarding become founder-dependent and inconsistent.
|
||||||
|
- **Why it matters**: TenantPilot deals with tenant governance artifacts and Microsoft configuration data. Trust documentation is not just legal paperwork; it is a sales and support scalability asset. It also forces the product to stay honest about what it stores, processes, and exposes.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- create a lightweight security trust pack aligned to the actual product architecture and data model
|
||||||
|
- document hosting, data categories, permission model, least-privilege stance, RBAC, audit logging, backup/retention, subprocessors, and non-stored data
|
||||||
|
- map claims to product features and architecture, avoiding unsupported compliance or certification claims
|
||||||
|
- keep the pack versioned and updateable as product capabilities change
|
||||||
|
- identify any product gaps that block truthful trust claims and feed those back into roadmap/spec candidates
|
||||||
|
- **Scope boundaries**:
|
||||||
|
- **In scope**: structured trust-pack content, product-data mapping, permission explanation, security overview, gap list, and maintenance ownership
|
||||||
|
- **Out of scope**: legal finalization, ISO/SOC2 certification, public trust center portal, penetration test execution, or broad security program implementation
|
||||||
|
- **Acceptance points**:
|
||||||
|
- trust pack answers the standard first-pass customer security questions consistently
|
||||||
|
- Microsoft permission explanations match actual provider scopes and product behavior
|
||||||
|
- data categories and retention claims map to real tables/artifacts or documented operating processes
|
||||||
|
- unsupported claims are explicitly avoided
|
||||||
|
- product gaps discovered during trust-pack creation are recorded as roadmap/spec candidates when engineering work is required
|
||||||
|
- **Risks / open questions**:
|
||||||
|
- This is partly non-code work; only engineering gaps should become implementation specs
|
||||||
|
- Legal review may change wording, but not the underlying product truth
|
||||||
|
- Over-claiming compliance posture would damage trust
|
||||||
|
- **Dependencies**: Provider permission model, RBAC model, audit logs, retention behavior, backup behavior, deployment/hosting decisions, AVV/DPA/TOM work
|
||||||
|
- **Related specs / candidates**: Plans / Entitlements & Billing Readiness, System Panel Least-Privilege Capability Model, Provider Boundary Hardening, Evidence domain
|
||||||
|
- **Strategic sequencing**: Should run before serious enterprise sales conversations and before broad customer onboarding.
|
||||||
|
- **Priority**: medium-high
|
||||||
|
|
||||||
|
### AI-Assisted Customer Operations
|
||||||
|
- **Type**: AI-assisted operations / human-in-the-loop product support
|
||||||
|
- **Source**: roadmap update 2026-04-25 — Mid-term AI-Assisted Customer Operations
|
||||||
|
- **Problem**: Customer reviews, support triage, finding explanations, diagnostic summaries, release communication, and report summaries can consume large amounts of founder time. However, unbounded AI automation would be risky in a governance product, especially for tenant-changing actions, customer commitments, legal statements, or risk decisions.
|
||||||
|
- **Why it matters**: TenantPilot can use AI to stay lean, but the product must preserve auditability, human approval, and clear responsibility. The right early AI layer prepares and summarizes work; it does not autonomously change customer tenants or make commitments.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- use structured product truth from diagnostic packs, findings, stored reports, evidence, operation runs, and the product knowledge registry as AI input
|
||||||
|
- generate draft support summaries, finding explanations, tenant review summaries, diagnostic summaries, release-note drafts, and response drafts
|
||||||
|
- require human approval before customer-facing messages, legal statements, risk acceptance, or tenant-changing actions
|
||||||
|
- log AI-generated drafts and human approval where product-relevant
|
||||||
|
- define safety boundaries for what AI can read, suggest, and never execute
|
||||||
|
- **Scope boundaries**:
|
||||||
|
- **In scope**: AI draft/summarization workflows for support, findings, reviews, diagnostics, release notes, and customer explanations; approval gates; audit references; source attribution to product records
|
||||||
|
- **Out of scope**: autonomous tenant remediation, automatic risk acceptance, automatic legal commitments, auto-sending customer communications without review, general-purpose chatbot, or broad AI platform redesign
|
||||||
|
- **Acceptance points**:
|
||||||
|
- generated summaries cite or reference underlying product records rather than inventing unsupported conclusions
|
||||||
|
- customer-facing drafts require human approval before sending or publishing
|
||||||
|
- tenant-changing actions are not executed by AI in this spec
|
||||||
|
- AI access is scoped and redacted through existing permission/diagnostic-pack boundaries
|
||||||
|
- operators can distinguish draft AI text from approved product/customer communication
|
||||||
|
- **Risks / open questions**:
|
||||||
|
- AI hallucination risk must be mitigated through structured inputs and source references
|
||||||
|
- Privacy and data-processing boundaries need explicit review before customer data is sent to any model provider
|
||||||
|
- The first version should probably be internal-only until diagnostics, knowledge, and support-request foundations are stable
|
||||||
|
- **Dependencies**: Support Diagnostic Pack, Product Knowledge & Contextual Help, In-App Support Request with Context, StoredReports / EvidenceItems, Findings workflow, release communication process, security/privacy review
|
||||||
|
- **Related specs / candidates**: Human-in-the-Loop Autonomous Governance, Operator Explanation Layer, Humanized Diagnostic Summaries for Governance Operations, Security Trust Pack Light
|
||||||
|
- **Strategic sequencing**: Mid-term. Do not promote before diagnostic, knowledge, and support-context foundations exist.
|
||||||
|
- **Priority**: medium
|
||||||
|
|
||||||
|
> Recommended sequence for this cluster:
|
||||||
|
> 1. **Self-Service Tenant Onboarding & Connection Readiness**
|
||||||
|
> 2. **Support Diagnostic Pack**
|
||||||
|
> 3. **Product Knowledge & Contextual Help**
|
||||||
|
> 4. **In-App Support Request with Context**
|
||||||
|
> 5. **Plans, Entitlements & Billing Readiness**
|
||||||
|
> 6. **Demo & Trial Readiness**
|
||||||
|
> 7. **Security Trust Pack Light**
|
||||||
|
> 8. **AI-Assisted Customer Operations**
|
||||||
|
>
|
||||||
|
|
||||||
|
|
||||||
|
> Additional Solo-Founder Scale Guardrails cluster: these candidates come from the roadmap update on 2026-04-25. The goal is to make the highest-impact solo-founder operating risks measurable, controllable, and product-backed without turning TenantPilot into a CRM, helpdesk, analytics suite, or generic backoffice platform. Pure company-ops artifacts stay in the roadmap; the candidates below are only the product-impacting slices.
|
||||||
|
|
||||||
|
### Product Usage & Adoption Telemetry
|
||||||
|
- **Type**: product observability / adoption analytics foundation
|
||||||
|
- **Source**: roadmap update 2026-04-25 — Additional Solo-Founder Scale Guardrails and Product Usage, Customer Health & Operational Controls
|
||||||
|
- **Problem**: TenantPilot currently risks relying on founder intuition, support tickets, or manual database/log inspection to understand onboarding drop-off, feature adoption, trial health, failed flows, report/export usage, and support-triggering surfaces. Without privacy-aware product telemetry, it is hard to know where customers get stuck or which product areas actually drive value.
|
||||||
|
- **Why it matters**: Low-headcount SaaS requires the product to reveal adoption and friction automatically. Telemetry is also a prerequisite for Customer Health Score, lifecycle communication, trial conversion analysis, and prioritizing product work based on behavior rather than anecdotes.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- define a minimal product telemetry event contract for product usage and adoption signals
|
||||||
|
- capture events such as onboarding step completed/blocked, provider connection checked, baseline capture/compare started, report exported, review pack generated, support request opened, contextual help opened, and trial activation milestones
|
||||||
|
- keep events workspace-/tenant-aware but privacy-aware and avoid raw provider payloads or customer-sensitive data in telemetry
|
||||||
|
- model event name, actor, workspace, tenant, feature area, subject reference, timestamp, and safe metadata
|
||||||
|
- provide aggregate read models for adoption dashboards and customer health scoring
|
||||||
|
- document telemetry boundaries and opt-out / data-processing considerations where appropriate
|
||||||
|
- **Scope boundaries**:
|
||||||
|
- **In scope**: internal product telemetry event model, minimal event capture points, privacy/redaction rules, aggregate usage read model, basic operator visibility, and tests for isolation/redaction
|
||||||
|
- **Out of scope**: full analytics platform, third-party product analytics integration, marketing attribution, session recording, user tracking beyond product-operation needs, or broad BI dashboards
|
||||||
|
- **Acceptance points**:
|
||||||
|
- key onboarding, governance, report/export, and support-intake events can be captured through a central contract
|
||||||
|
- telemetry metadata never stores raw provider payloads or secrets
|
||||||
|
- workspace/tenant isolation is enforced for telemetry reads
|
||||||
|
- aggregate adoption indicators can be queried without scanning arbitrary application logs
|
||||||
|
- telemetry capture can be disabled or bounded by configuration where needed
|
||||||
|
- **Risks / open questions**:
|
||||||
|
- Telemetry must not become invasive or create unnecessary privacy exposure
|
||||||
|
- Too many events too early can create noise; start with high-signal product milestones
|
||||||
|
- Decide whether telemetry is stored in the primary database initially or written through an adapter seam for future external analytics
|
||||||
|
- **Dependencies**: Self-Service Tenant Onboarding & Connection Readiness, OperationRun truth, ProviderConnection health, StoredReports / EvidenceItems, Support Diagnostic Pack, audit/data-processing review
|
||||||
|
- **Related specs / candidates**: Customer Health Score, Customer Lifecycle Communication, Plans / Entitlements & Billing Readiness, Security Trust Pack Light
|
||||||
|
- **Strategic sequencing**: First item in this guardrails cluster because health score and lifecycle communication need reliable usage signals.
|
||||||
|
- **Priority**: high
|
||||||
|
|
||||||
|
### Customer Health Score
|
||||||
|
- **Type**: product observability / customer success signal
|
||||||
|
- **Source**: roadmap update 2026-04-25 — Additional Solo-Founder Scale Guardrails and Product Usage, Customer Health & Operational Controls
|
||||||
|
- **Problem**: Churn, inactive customers, unhealthy provider connections, stale baseline compares, unresolved high-risk findings, overdue SLAs, failed runs, expiring risk acceptances, and missing review packs may be noticed too late if the founder has to manually inspect each workspace.
|
||||||
|
- **Why it matters**: A solo-founder or low-headcount SaaS needs a simple, trustworthy signal for which customers or workspaces need attention. This is especially important for MSP-oriented governance, where portfolio risk can grow silently across many tenants.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- derive workspace/customer health indicators from product truth instead of manual notes
|
||||||
|
- combine signals such as onboarding status, last login/activity, provider health, last successful sync, baseline compare freshness, open high findings, overdue findings, expiring risk acceptances, failed/stale OperationRuns, support-request volume, review-pack readiness, and trial/billing status where available
|
||||||
|
- separate health dimensions rather than hiding everything in one opaque score
|
||||||
|
- provide a simple health summary for founder/operator views and later portfolio surfaces
|
||||||
|
- keep customer-health calculations explainable and link back to underlying records
|
||||||
|
- **Scope boundaries**:
|
||||||
|
- **In scope**: health signal registry, derived health dimensions, explainable health summary, workspace/customer-level health read model, first operator view or dashboard card, and tests
|
||||||
|
- **Out of scope**: full customer-success CRM, automated churn prediction, external CRM sync, billing collection, sales pipeline scoring, or AI-generated account management actions
|
||||||
|
- **Acceptance points**:
|
||||||
|
- a workspace/customer health summary can be generated from product data
|
||||||
|
- each health warning links to underlying evidence such as provider health, findings, operations, review packs, or trial state
|
||||||
|
- stale/unknown data is represented explicitly and does not appear healthy by default
|
||||||
|
- customer health is scoped by workspace and respects authorization boundaries
|
||||||
|
- at least one dashboard or operator surface can list unhealthy or attention-needed workspaces
|
||||||
|
- **Risks / open questions**:
|
||||||
|
- A single numeric score can hide important nuance; dimensions should remain visible
|
||||||
|
- Missing data must not be treated as good data
|
||||||
|
- The first version should avoid predictive claims and stay evidence-based
|
||||||
|
- **Dependencies**: Product Usage & Adoption Telemetry, ProviderConnection health, OperationRun truth, Findings workflow, Risk Acceptance/Exceptions, StoredReports / EvidenceItems, Plans / Entitlements & Billing Readiness
|
||||||
|
- **Related specs / candidates**: MSP Portfolio Dashboard, Product Usage & Adoption Telemetry, Customer Lifecycle Communication, Support Diagnostic Pack
|
||||||
|
- **Strategic sequencing**: Second item after telemetry. It should follow reliable signal capture and feed portfolio/customer-success views later.
|
||||||
|
- **Priority**: high
|
||||||
|
|
||||||
|
### Operational Controls & Feature Flags
|
||||||
|
- **Type**: operational safety / platform control plane
|
||||||
|
- **Source**: roadmap update 2026-04-25 — Additional Solo-Founder Scale Guardrails and Product Usage, Customer Health & Operational Controls
|
||||||
|
- **Problem**: Incidents or risky product areas may otherwise require code changes, deployments, manual database edits, or ad-hoc communication to pause a feature, block provider-backed actions, disable exports, pause AI functions, stop trials, or place a workspace into a temporary safe state.
|
||||||
|
- **Why it matters**: Solo-founder operations need safe operator controls. TenantPilot contains high-trust workflows such as restore, provider-backed actions, exports, AI-assisted summaries, and evidence/report generation. These need controlled kill switches and scoped feature flags before scale increases incident pressure.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- introduce a minimal operational controls registry with global, workspace, and possibly tenant scope
|
||||||
|
- support kill switches / flags for risky features such as restore execution, provider-backed writes, exports, AI functions, trial provisioning, report generation, and maintenance/read-only modes
|
||||||
|
- expose operator-safe controls in the system/platform plane with strong capabilities and audit logging
|
||||||
|
- define enforcement points through services/gates rather than UI-only hiding
|
||||||
|
- allow time-bound controls with reason and owner where useful
|
||||||
|
- provide clear customer/operator messaging when a feature is disabled or paused
|
||||||
|
- **Scope boundaries**:
|
||||||
|
- **In scope**: feature flag / operational control model, scoped evaluation service, audited changes, first enforcement points, platform/system UI for controls, and tests
|
||||||
|
- **Out of scope**: full experimentation platform, A/B testing, remote-config product, external feature flag vendor integration, broad entitlement replacement, or customer-managed feature flags
|
||||||
|
- **Acceptance points**:
|
||||||
|
- at least one risky feature can be disabled globally and per workspace through a central control
|
||||||
|
- enforcement happens server-side at the action/service boundary
|
||||||
|
- changes are audited with actor, scope, reason, and timestamp
|
||||||
|
- disabled-state messaging is explicit and not confused with authorization failure
|
||||||
|
- tests prove UI hiding is not the only enforcement mechanism
|
||||||
|
- **Risks / open questions**:
|
||||||
|
- Operational controls must not bypass entitlement/RBAC semantics or become an untracked superpower
|
||||||
|
- Too many flags can create configuration drift; start with high-risk controls only
|
||||||
|
- Read-only modes need careful definition so evidence/audit access remains available
|
||||||
|
- **Dependencies**: System Panel Least-Privilege Capability Model, Provider-Backed Action Preflight and Dispatch Gate Unification, restore/provider action services, export/report services, audit log foundation, Plans / Entitlements & Billing Readiness
|
||||||
|
- **Related specs / candidates**: Provider-Backed Action Preflight and Dispatch Gate Unification, Plans / Entitlements & Billing Readiness, System Panel Least-Privilege Capability Model, Business Continuity / Founder Backup Plan
|
||||||
|
- **Strategic sequencing**: High priority once external customers or pilots depend on production. Can be promoted before telemetry if incident-control risk becomes immediate.
|
||||||
|
- **Priority**: high
|
||||||
|
|
||||||
|
### Customer Lifecycle Communication
|
||||||
|
- **Type**: customer operations / notification automation
|
||||||
|
- **Source**: roadmap update 2026-04-25 — Additional Solo-Founder Scale Guardrails
|
||||||
|
- **Problem**: Welcome messages, onboarding reminders, trial expiry, provider health warnings, review-pack readiness, risk-expiry reminders, release updates, incidents, renewals, payment issues, and churn-feedback requests can become manual founder communication if they are not structured.
|
||||||
|
- **Why it matters**: Repeatable SaaS delivery depends on consistent customer communication. Some messages are product-triggered and should be model-backed; others belong to company operations. TenantPilot needs a clear product boundary so important lifecycle events can trigger communication without creating a generic marketing automation system.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- define product-triggerable lifecycle communication events for high-value operational moments
|
||||||
|
- start with onboarding incomplete, provider unhealthy, review pack ready, risk acceptance expiring, trial expiring, incident/update notice, and release note availability where product-backed
|
||||||
|
- support templates, recipient resolution, locale, delivery channel abstraction, and audit/reference links where appropriate
|
||||||
|
- distinguish internal operator reminders from customer-facing communication
|
||||||
|
- keep marketing campaigns and CRM nurture sequences outside the first product slice
|
||||||
|
- **Scope boundaries**:
|
||||||
|
- **In scope**: product lifecycle event contract, template registry, recipient resolution, first delivery adapter or outbound hook, audit/reference behavior, and tests for tenant/workspace isolation
|
||||||
|
- **Out of scope**: full marketing automation, newsletter system, CRM pipeline, payment collection, two-way communication inbox, or generic campaign builder
|
||||||
|
- **Acceptance points**:
|
||||||
|
- at least two product-backed lifecycle events can generate structured communication tasks or outbound messages
|
||||||
|
- recipient selection respects workspace/tenant/customer membership and locale where applicable
|
||||||
|
- customer-facing messages reference the relevant product object such as tenant, run, finding, review pack, or risk acceptance
|
||||||
|
- communications are auditable or at least traceable to a product event
|
||||||
|
- customer-facing communication can be disabled or held for manual approval where needed
|
||||||
|
- **Risks / open questions**:
|
||||||
|
- Over-automated customer communication can become noisy or risky during incidents
|
||||||
|
- Billing/payment messages may depend on external billing systems and should not be over-modeled too early
|
||||||
|
- Legal/customer-facing statements may need approval rules before automatic sending
|
||||||
|
- **Dependencies**: Notification Targets / Alerts v1, Product Knowledge & Contextual Help, Plans / Entitlements & Billing Readiness, Customer Health Score, Risk Acceptance/Exceptions, review-pack generation
|
||||||
|
- **Related specs / candidates**: Alerts v1, AI-Assisted Customer Operations, Product Knowledge & Contextual Help, Release & Customer Communication Automation
|
||||||
|
- **Strategic sequencing**: Medium-high. Should follow the first telemetry/health foundations and reuse existing alert/notification infrastructure where possible.
|
||||||
|
- **Priority**: medium-high
|
||||||
|
|
||||||
|
### Product Intake & No-Customization Governance
|
||||||
|
- **Type**: product operations / roadmap governance
|
||||||
|
- **Source**: roadmap update 2026-04-25 — Additional Solo-Founder Scale Guardrails
|
||||||
|
- **Problem**: Customer-specific requests can silently turn TenantPilot into consulting work if they are implemented as one-off behavior, hidden configuration, or customer-specific branches. Without a product intake and no-customization governance path, each sales/support conversation can create long-term maintenance obligations.
|
||||||
|
- **Why it matters**: A low-headcount SaaS must protect the product boundary. Feature requests should become product input, not direct custom work by default. This is especially important for MSP and enterprise customers, where individual requests can sound urgent but may not fit the platform direction.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- define a lightweight feature/request intake model or documented operating process
|
||||||
|
- classify requests as no, later, candidate, planned, customer-specific exception, or already covered
|
||||||
|
- capture customer/segment, problem, workaround, business value, roadmap fit, and maintenance risk
|
||||||
|
- link accepted requests to spec candidates or promoted specs where appropriate
|
||||||
|
- require explicit approval and audit/record for any customer-specific exception
|
||||||
|
- document the no-custom-work policy in product principles or company operating guidance
|
||||||
|
- **Scope boundaries**:
|
||||||
|
- **In scope**: product request classification, link to roadmap/spec candidates, exception semantics, optional internal admin surface, and no-customization policy wording
|
||||||
|
- **Out of scope**: full product management suite, voting portal, public roadmap, customer community, consulting project management, or CRM replacement
|
||||||
|
- **Acceptance points**:
|
||||||
|
- customer requests can be classified consistently without becoming immediate implementation tasks
|
||||||
|
- customer-specific exceptions are explicit, rare, and reviewable
|
||||||
|
- accepted product requests can link to spec candidates or roadmap themes
|
||||||
|
- no-custom-work policy is visible in product/company guidance
|
||||||
|
- the process can be operated manually at first but is structured enough to delegate later
|
||||||
|
- **Risks / open questions**:
|
||||||
|
- This may be mostly process at first; only build product surfaces if manual tracking becomes a bottleneck
|
||||||
|
- Too much process too early could slow learning from pilots
|
||||||
|
- Exceptions need a business owner and expiry/review path so they do not become permanent hidden product variants
|
||||||
|
- **Dependencies**: roadmap/spec-candidate process, principles/constitution, customer support/intake process, Plans / Entitlements if exceptions affect limits or features
|
||||||
|
- **Related specs / candidates**: Plans / Entitlements & Billing Readiness, Customer Lifecycle Communication, Security Trust Pack Light
|
||||||
|
- **Strategic sequencing**: Medium. Add as a principle/process early; promote to product spec only if in-product request/exception tracking becomes necessary.
|
||||||
|
- **Priority**: medium
|
||||||
|
|
||||||
|
### Data Retention, Export & Deletion Self-Service
|
||||||
|
- **Type**: data lifecycle / customer trust / operational scalability
|
||||||
|
- **Source**: roadmap update 2026-04-25 — Additional Solo-Founder Scale Guardrails
|
||||||
|
- **Problem**: Customer data export, archive, deletion request handling, trial data expiry, workspace deactivation, and evidence/report retention visibility can become manual support/legal work if the product does not provide clear lifecycle controls and customer-safe visibility.
|
||||||
|
- **Why it matters**: TenantPilot stores governance artifacts, evidence, reports, findings, and operation history. Customers will ask what is retained, what can be exported, what is deleted, and what remains for audit purposes. Self-service or operator-guided lifecycle flows reduce manual work and improve trust.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- define a customer/workspace data lifecycle contract covering active, suspended, archived, trial-expired, deletion-requested, and deleted/retained states where appropriate
|
||||||
|
- expose retention visibility for reports, evidence, operation runs, findings, exceptions, and backups where already modeled
|
||||||
|
- provide customer/operator export request flows and deletion/archive request flows with audit events
|
||||||
|
- make trial data expiry explicit and configurable where tied to plan/entitlement state
|
||||||
|
- distinguish audit-retained records from deleted customer content and communicate that boundary clearly
|
||||||
|
- **Scope boundaries**:
|
||||||
|
- **In scope**: lifecycle state model or request model where needed, export/deletion request flow, retention visibility, audit events, trial expiry handling, and tests for authorization/isolation
|
||||||
|
- **Out of scope**: full GDPR portal, legal policy drafting, automated physical deletion of every historical artifact without retention analysis, external DSR tooling, or broad storage-engine redesign
|
||||||
|
- **Acceptance points**:
|
||||||
|
- customers/operators can see or request export/deletion/archive actions through a defined flow
|
||||||
|
- retention behavior for key artifact families is visible or documented in-product where appropriate
|
||||||
|
- trial-expired data handling is explicit and not ad-hoc
|
||||||
|
- deletion/archive requests are audited and authorized
|
||||||
|
- audit-retained metadata is clearly separated from customer content deletion semantics
|
||||||
|
- **Risks / open questions**:
|
||||||
|
- Legal retention, auditability, and deletion rights must be balanced carefully
|
||||||
|
- Evidence/report retention may intentionally outlive operation runs; this must be visible and not surprising
|
||||||
|
- Automation should start conservative until legal review confirms deletion/retention expectations
|
||||||
|
- **Dependencies**: StoredReports retention, EvidenceItems retention, OperationRun retention, backup retention, Plans / Entitlements & Billing Readiness, Security Trust Pack Light, audit log foundation
|
||||||
|
- **Related specs / candidates**: StoredReports Model, EvidenceItem Model, Export v1, Security Trust Pack Light, Customer Review Workspace v1
|
||||||
|
- **Strategic sequencing**: Medium-high. Should be shaped before broad paid trials and enterprise security reviews, but can land after entitlement and trust-pack foundations.
|
||||||
|
- **Priority**: medium-high
|
||||||
|
|
||||||
|
> Recommended sequence for this cluster:
|
||||||
|
> 1. **Product Usage & Adoption Telemetry**
|
||||||
|
> 2. **Customer Health Score**
|
||||||
|
> 3. **Operational Controls & Feature Flags**
|
||||||
|
> 4. **Data Retention, Export & Deletion Self-Service**
|
||||||
|
> 5. **Customer Lifecycle Communication**
|
||||||
|
> 6. **Product Intake & No-Customization Governance**
|
||||||
|
>
|
||||||
|
> Why this order: first capture reliable signals, then derive health and risk, then add operator control for incidents and risky features, then close customer trust/lifecycle gaps, then automate customer communication, and finally formalize request intake/no-customization once pilot feedback volume increases.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
> Microsoft-first, Provider-extensible Decision-Based Operating cluster: these candidates come from the roadmap update on 2026-04-25. The goal is to move TenantPilot from Microsoft tenant search-and-troubleshoot workflows to decision-based governance operations. Customers and operators should not have to click through tenants, runs, findings, reports, logs, and evidence to discover what needs attention. TenantPilot should detect relevant work, gather context, summarize impact, propose safe next actions, and present decision-ready items. The first product scope stays Microsoft tenant governance; the decision model should avoid hard-coding Microsoft-only assumptions where provider-neutral abstractions already exist.
|
||||||
|
|
||||||
|
### Decision-Based Governance Inbox v1
|
||||||
|
- **Type**: product strategy / workflow automation / operator UX
|
||||||
|
- **Source**: roadmap update 2026-04-25 — Human-in-the-Loop Autonomous Governance (Microsoft-first, Provider-extensible Decision-Based Operating)
|
||||||
|
- **Problem**: TenantPilot has many rich governance surfaces, but customers and operators can still be forced into search-and-troubleshoot behavior: opening tenants, runs, findings, reports, evidence, provider health, and logs to discover what actually needs a decision. That does not scale for MSPs, customer read-only users, or a low-headcount operating model.
|
||||||
|
- **Why it matters**: TenantPilot should become the decision control plane for accountable Microsoft tenant governance first, not just a browser for tenant state and execution history. The default workflow should be guided decisions; raw detail pages remain available as evidence and diagnostics.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- introduce a Governance Inbox / Action Center that surfaces decision-ready work items across tenants and workspaces
|
||||||
|
- derive inbox items from findings, drift, exceptions, risk acceptances, provider health, failed/stale OperationRuns, review-pack readiness, evidence gaps, and actionable alerts
|
||||||
|
- group, deduplicate, and prioritize related signals so operators do not work the same issue multiple times
|
||||||
|
- show clear decision actions such as review, approve, reject, snooze, assign, accept risk, create ticket, run compare, generate review pack, or request evidence
|
||||||
|
- link every inbox item to underlying evidence and diagnostic surfaces without making drilldown the primary workflow
|
||||||
|
- keep the first implementation Microsoft-first while using provider-neutral descriptors where existing platform abstractions support it
|
||||||
|
- **Scope boundaries**:
|
||||||
|
- **In scope**: decision inbox item model/read model, source adapters for a small set of high-value signals, grouping/dedup rules, severity/priority handling, action affordances, links to evidence/diagnostics, RBAC/workspace scoping, and first operator UI
|
||||||
|
- **Out of scope**: autonomous remediation, broad AI agent, full workflow engine, complete MSP portfolio dashboard replacement, customer-facing remediation actions without approval, or support/CRM replacement
|
||||||
|
- **Acceptance points**:
|
||||||
|
- operators can see a prioritized list of decision-ready governance items without manually visiting each tenant/run/finding/report first
|
||||||
|
- each item includes why it matters, affected tenant/workspace, source records, severity/priority, freshness, and available actions
|
||||||
|
- duplicate/related signals can be grouped or fingerprinted to avoid inbox noise
|
||||||
|
- actions are server-side authorized and routed through existing OperationRun/workflow/audit patterns where applicable
|
||||||
|
- detail pages are reachable as evidence, but the main workflow remains decision-first
|
||||||
|
- tests prove workspace/tenant isolation and prevent unrelated users from seeing inbox items
|
||||||
|
- **Risks / open questions**:
|
||||||
|
- Inbox noise is a major risk; grouping and confidence/freshness semantics matter from v1
|
||||||
|
- The inbox must not become another dashboard that merely links to raw tables
|
||||||
|
- The first slice needs carefully selected sources, likely findings, provider health, stale/failed runs, expiring risk acceptances, and review-pack readiness
|
||||||
|
- Customer-facing visibility may need a later slice with redaction and read-only action limits
|
||||||
|
- **Dependencies**: Findings workflow, Risk Acceptance/Exceptions, OperationRun truth, ProviderConnection health, StoredReports / EvidenceItems, Alerts v1, Product Usage & Adoption Telemetry, Customer Health Score, Operational Controls & Feature Flags
|
||||||
|
- **Related specs / candidates**: Decision Pack Contract & Approval Workflow, Findings Operator Inbox v1, Findings Intake & Team Queue v1, Customer Review Workspace v1, MSP Portfolio Dashboard, AI-Assisted Customer Operations
|
||||||
|
- **Strategic sequencing**: High priority after onboarding/support/telemetry/control foundations because it converts those signals into the primary customer/operator workflow.
|
||||||
|
- **Priority**: high
|
||||||
|
|
||||||
|
### Decision Pack Contract & Approval Workflow
|
||||||
|
- **Type**: workflow automation / human-in-the-loop governance contract
|
||||||
|
- **Source**: roadmap update 2026-04-25 — Human-in-the-Loop Autonomous Governance (Microsoft-first, Provider-extensible Decision-Based Operating)
|
||||||
|
- **Problem**: A decision inbox is only useful if each item contains enough context to make a safe decision. Without a structured decision pack, operators still have to manually correlate drift, findings, evidence, operations, provider state, risk acceptance, and recommended action before approving or rejecting work.
|
||||||
|
- **Why it matters**: Human-in-the-loop governance depends on trustworthy, reviewable decision packages: what happened, why it matters, what evidence supports it, what options exist, what the system recommends, what confidence/freshness applies, and what will happen if the operator approves. This is the bridge between detection and controlled execution.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- define a Decision Pack contract with summary, impact, affected tenants/policies, source signals, evidence links, confidence/freshness, recommended action, available actions, and expected execution path
|
||||||
|
- include before/after evidence requirements where an approved action triggers follow-up execution
|
||||||
|
- require human approval for tenant-changing, customer-facing, or risk-accepting actions
|
||||||
|
- route approved follow-up through OperationRuns or controlled workflows rather than direct UI-side execution
|
||||||
|
- audit detection, recommendation, approval/rejection, execution, verification, and evidence attachment
|
||||||
|
- keep Microsoft-specific details contextual while preserving provider-neutral subject/action vocabulary where possible
|
||||||
|
- **Scope boundaries**:
|
||||||
|
- **In scope**: Decision Pack data contract, approval state machine, action registry for first safe actions, audit events, OperationRun handoff, evidence requirements, and tests
|
||||||
|
- **Out of scope**: autonomous remediation, broad policy engine, multi-approver enterprise workflow, advanced AI recommendation engine, external ticketing deep sync, or automatic legal/customer commitments
|
||||||
|
- **Acceptance points**:
|
||||||
|
- a decision pack can be generated for at least one high-value decision source such as critical drift, expiring risk acceptance, failed compare, or review-pack readiness
|
||||||
|
- the pack shows summary, impact, evidence, source records, recommendation, confidence/freshness, and available actions
|
||||||
|
- approval/rejection/snooze/assign actions are audited
|
||||||
|
- tenant-changing or customer-facing actions require explicit approval before execution
|
||||||
|
- approved execution creates or references an OperationRun or controlled workflow record
|
||||||
|
- verification and before/after evidence can be attached or requested where applicable
|
||||||
|
- **Risks / open questions**:
|
||||||
|
- Too much context can overwhelm operators; the pack must be concise with progressive disclosure
|
||||||
|
- Recommendations must not overstate certainty; confidence/freshness must be visible
|
||||||
|
- AI-generated recommendations should remain optional and clearly marked until AI governance boundaries are mature
|
||||||
|
- **Dependencies**: Decision-Based Governance Inbox v1, Support Diagnostic Pack, Product Knowledge & Contextual Help, OperationRun link contract, Findings workflow, StoredReports / EvidenceItems, Operational Controls & Feature Flags, audit log foundation
|
||||||
|
- **Related specs / candidates**: AI-Assisted Customer Operations, Operator Explanation Layer, Humanized Diagnostic Summaries for Governance Operations, Provider-Backed Action Preflight and Dispatch Gate Unification, Customer Lifecycle Communication
|
||||||
|
- **Strategic sequencing**: Should follow or pair with Governance Inbox v1. The inbox defines the work queue; decision packs make each item decision-ready.
|
||||||
|
- **Priority**: high
|
||||||
|
|
||||||
|
### Governance Automation Policy Guardrails v1
|
||||||
|
- **Type**: automation policy / safety guardrails / future autonomous governance foundation
|
||||||
|
- **Source**: roadmap update 2026-04-25 — Human-in-the-Loop Autonomous Governance (Microsoft-first, Provider-extensible Decision-Based Operating)
|
||||||
|
- **Problem**: As TenantPilot moves from detection to guided action, there will be pressure to automate more of the workflow. Without explicit automation policy guardrails, the product risks drifting into unsafe autopilot behavior or, conversely, never automating safe low-risk follow-up.
|
||||||
|
- **Why it matters**: The product promise is not blind automation. It is accountable governance with human approval where risk matters. Automation policies should define what can be auto-created, auto-assigned, auto-snoozed, auto-notified, or auto-executed, and where approval is mandatory.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- define automation policy guardrails for decision item creation, grouping, assignment, notifications, snoozing, ticket creation, review-pack generation, compare runs, and future remediation execution
|
||||||
|
- classify actions by risk: informational, workflow-only, customer-facing, tenant-changing, risk-accepting, or destructive
|
||||||
|
- require approval for tenant-changing, customer-facing, risk-accepting, or destructive actions
|
||||||
|
- support workspace-level policy defaults and optional stricter tenant-level overrides later
|
||||||
|
- audit policy changes and automation outcomes
|
||||||
|
- integrate with Operational Controls & Feature Flags so automation can be paused safely
|
||||||
|
- **Scope boundaries**:
|
||||||
|
- **In scope**: first automation policy model, action risk taxonomy, approval-required rules, audited policy changes, and enforcement for a small set of workflow-safe actions
|
||||||
|
- **Out of scope**: full rules engine, customer-authored automation scripting, autonomous remediation, complex multi-step playbooks, or cross-provider policy marketplace
|
||||||
|
- **Acceptance points**:
|
||||||
|
- automation policies can distinguish safe workflow automation from approval-required actions
|
||||||
|
- at least one safe action can run automatically and at least one risky action is blocked until approval
|
||||||
|
- policy changes are audited with actor, reason, scope, and timestamp
|
||||||
|
- disabled automation states are clear to operators
|
||||||
|
- tests prove tenant-changing and risk-accepting actions cannot bypass approval through automation
|
||||||
|
- **Risks / open questions**:
|
||||||
|
- Premature policy complexity could slow delivery; start with a small risk taxonomy and a few actions
|
||||||
|
- Workspace vs tenant policy inheritance must be handled carefully to avoid surprising behavior
|
||||||
|
- Automation policy should align with future MSP baseline inheritance and customer override semantics
|
||||||
|
- **Dependencies**: Decision-Based Governance Inbox v1, Decision Pack Contract & Approval Workflow, Operational Controls & Feature Flags, RBAC/capabilities, audit log foundation, Customer Lifecycle Communication
|
||||||
|
- **Related specs / candidates**: Human-in-the-Loop Autonomous Governance, MSP Portfolio Dashboard, Rollouts v1, Customer Review Workspace v1, AI-Assisted Customer Operations
|
||||||
|
- **Strategic sequencing**: Medium-high. It should not precede decision inbox and decision pack foundations, but it should land before any autonomous or semi-autonomous remediation features.
|
||||||
|
- **Priority**: medium-high
|
||||||
|
|
||||||
|
> Recommended sequence for this cluster:
|
||||||
|
> 1. **Decision-Based Governance Inbox v1**
|
||||||
|
> 2. **Decision Pack Contract & Approval Workflow**
|
||||||
|
> 3. **Governance Automation Policy Guardrails v1**
|
||||||
|
>
|
||||||
|
> Why this order: first create the decision queue, then make each item decision-ready with evidence and approval semantics, then introduce explicit automation policy guardrails before expanding toward semi-autonomous execution.
|
||||||
|
|
||||||
### System Panel Least-Privilege Capability Model
|
### System Panel Least-Privilege Capability Model
|
||||||
- **Type**: security hardening / platform-plane RBAC
|
- **Type**: security hardening / platform-plane RBAC
|
||||||
|
|||||||
@ -0,0 +1,35 @@
|
|||||||
|
# Specification Quality Checklist: Canonical Operation Type Source of Truth
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-04-25
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The draft stays bounded to platform-core `operation_type` truth and explicitly avoids broader governed-subject or monitoring-IA cleanup.
|
||||||
|
- Canonical replacement is preferred, with only a narrowly bounded read-side compatibility seam acknowledged for historical rows and persisted onboarding draft state during rollout.
|
||||||
@ -0,0 +1,276 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: Canonical Operation Type Source of Truth Logical Contract
|
||||||
|
version: 0.1.0
|
||||||
|
description: |
|
||||||
|
Logical internal contract for converging operation identity on one canonical
|
||||||
|
dotted `operation_type` vocabulary. It describes shared shapes for resolving
|
||||||
|
raw values, normalizing onboarding bootstrap selections, and reading
|
||||||
|
provider-backed operation definitions under the same platform-owned truth.
|
||||||
|
Current-release writers must emit canonical dotted values directly. Legacy
|
||||||
|
aliases are only valid on read-side resolution, filter matching, queued-run
|
||||||
|
reauthorization, and onboarding draft normalization paths where
|
||||||
|
`write_allowed` remains false.
|
||||||
|
It is not a commitment to expose public HTTP routes.
|
||||||
|
paths:
|
||||||
|
/logical/operation-types/resolve/{rawValue}:
|
||||||
|
get:
|
||||||
|
summary: Resolve a raw operation value to one canonical operation contract
|
||||||
|
operationId: resolveOperationType
|
||||||
|
parameters:
|
||||||
|
- name: rawValue
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Canonical operation type resolution
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/OperationTypeResolution'
|
||||||
|
/logical/operation-types/filter-options:
|
||||||
|
post:
|
||||||
|
summary: Build canonical filter options from observed raw operation values
|
||||||
|
operationId: buildOperationTypeFilterOptions
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/OperationTypeFilterOptionsRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Canonical filter options and historical query values
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/OperationTypeFilterOptionsResponse'
|
||||||
|
/logical/onboarding/bootstrap-operation-types/normalize:
|
||||||
|
post:
|
||||||
|
summary: Normalize onboarding bootstrap selections to canonical operation types
|
||||||
|
operationId: normalizeOnboardingBootstrapOperationTypes
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/OnboardingBootstrapNormalizationRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Canonical onboarding bootstrap selection result
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/OnboardingBootstrapNormalizationResponse'
|
||||||
|
/logical/provider-operation-definitions/{operationType}:
|
||||||
|
get:
|
||||||
|
summary: Read provider-backed operation definition for a canonical operation type
|
||||||
|
description: |
|
||||||
|
Provider operation definitions are keyed by canonical dotted operation
|
||||||
|
codes only. Supplying a legacy alias is outside the write-time/provider
|
||||||
|
registry contract and must fail before any OperationRun is started.
|
||||||
|
operationId: getProviderOperationDefinition
|
||||||
|
parameters:
|
||||||
|
- name: operationType
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Provider-backed operation definition and binding status
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ProviderOperationDefinitionResponse'
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
WriteTruthStatus:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- canonical_only
|
||||||
|
- read_side_compatibility_only
|
||||||
|
- unknown
|
||||||
|
AliasStatus:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- canonical
|
||||||
|
- legacy_alias
|
||||||
|
- unknown
|
||||||
|
CanonicalOperationType:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
canonical_code:
|
||||||
|
type: string
|
||||||
|
display_label:
|
||||||
|
type: string
|
||||||
|
domain_key:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
expected_duration_seconds:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
supports_operator_explanation:
|
||||||
|
type: boolean
|
||||||
|
write_truth_status:
|
||||||
|
$ref: '#/components/schemas/WriteTruthStatus'
|
||||||
|
required:
|
||||||
|
- canonical_code
|
||||||
|
- display_label
|
||||||
|
- supports_operator_explanation
|
||||||
|
- write_truth_status
|
||||||
|
HistoricalOperationTypeAlias:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
raw_value:
|
||||||
|
type: string
|
||||||
|
canonical_code:
|
||||||
|
type: string
|
||||||
|
alias_status:
|
||||||
|
$ref: '#/components/schemas/AliasStatus'
|
||||||
|
write_allowed:
|
||||||
|
type: boolean
|
||||||
|
retirement_boundary:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- canonical_current_truth
|
||||||
|
- read_side_rollout_seam
|
||||||
|
- unknown
|
||||||
|
retirement_note:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
required:
|
||||||
|
- raw_value
|
||||||
|
- canonical_code
|
||||||
|
- alias_status
|
||||||
|
- write_allowed
|
||||||
|
- retirement_boundary
|
||||||
|
OperationTypeResolution:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
raw_value:
|
||||||
|
type: string
|
||||||
|
canonical:
|
||||||
|
$ref: '#/components/schemas/CanonicalOperationType'
|
||||||
|
alias_status:
|
||||||
|
$ref: '#/components/schemas/AliasStatus'
|
||||||
|
was_legacy_alias:
|
||||||
|
type: boolean
|
||||||
|
allowed_query_values:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
aliases_considered:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/HistoricalOperationTypeAlias'
|
||||||
|
required:
|
||||||
|
- raw_value
|
||||||
|
- canonical
|
||||||
|
- alias_status
|
||||||
|
- was_legacy_alias
|
||||||
|
- allowed_query_values
|
||||||
|
- aliases_considered
|
||||||
|
OperationTypeFilterOption:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
operation_type:
|
||||||
|
type: string
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
raw_query_values:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- operation_type
|
||||||
|
- label
|
||||||
|
- raw_query_values
|
||||||
|
OperationTypeFilterOptionsRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
observed_raw_values:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- observed_raw_values
|
||||||
|
OperationTypeFilterOptionsResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
options:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/OperationTypeFilterOption'
|
||||||
|
required:
|
||||||
|
- options
|
||||||
|
OnboardingBootstrapNormalizationRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
bootstrap_operation_types:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- bootstrap_operation_types
|
||||||
|
OnboardingBootstrapNormalizationResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
canonical_operation_types:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
dropped_values:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
write_truth_status:
|
||||||
|
$ref: '#/components/schemas/WriteTruthStatus'
|
||||||
|
required:
|
||||||
|
- canonical_operation_types
|
||||||
|
- dropped_values
|
||||||
|
- write_truth_status
|
||||||
|
ProviderOperationDefinition:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
operation_type:
|
||||||
|
type: string
|
||||||
|
module:
|
||||||
|
type: string
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
required_capability:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- operation_type
|
||||||
|
- module
|
||||||
|
- label
|
||||||
|
- required_capability
|
||||||
|
ProviderOperationBinding:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
provider:
|
||||||
|
type: string
|
||||||
|
binding_status:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- active
|
||||||
|
- unsupported
|
||||||
|
write_truth_status:
|
||||||
|
$ref: '#/components/schemas/WriteTruthStatus'
|
||||||
|
required:
|
||||||
|
- provider
|
||||||
|
- binding_status
|
||||||
|
- write_truth_status
|
||||||
|
ProviderOperationDefinitionResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
definition:
|
||||||
|
$ref: '#/components/schemas/ProviderOperationDefinition'
|
||||||
|
binding:
|
||||||
|
$ref: '#/components/schemas/ProviderOperationBinding'
|
||||||
|
required:
|
||||||
|
- definition
|
||||||
|
- binding
|
||||||
190
specs/239-canonical-operation-type-source-of-truth/data-model.md
Normal file
190
specs/239-canonical-operation-type-source-of-truth/data-model.md
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
# Data Model: Canonical Operation Type Source of Truth
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature adds no new table, no new persisted entity, and no new status family. It tightens one existing platform-core contract: the dotted canonical `operation_type` definitions already described by `OperationCatalog` become the only normative write-time and registry-time truth for the touched slice. Historical aliases remain derived and read-side only during rollout.
|
||||||
|
|
||||||
|
## Entity: CanonicalOperationType
|
||||||
|
|
||||||
|
- **Type**: existing derived contract from `App\Support\OperationCatalog`
|
||||||
|
- **Purpose**: names one operation family consistently across writes, provider bindings, onboarding state, filters, audit metadata, related references, and operator labels.
|
||||||
|
|
||||||
|
### Identity
|
||||||
|
|
||||||
|
- `canonical_code` — stable dotted identifier such as `inventory.sync` or `backup.schedule.execute`
|
||||||
|
|
||||||
|
### Core Fields
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| `canonical_code` | string | Primary platform contract for touched writes and read models. |
|
||||||
|
| `display_label` | string | Operator-facing label resolved from `OperationCatalog`. |
|
||||||
|
| `domain_key` | string nullable | Existing domain grouping metadata; unchanged by this slice. |
|
||||||
|
| `expected_duration_seconds` | integer nullable | Existing Ops UX timing metadata; unchanged by this slice. |
|
||||||
|
| `supports_operator_explanation` | boolean | Existing explanation behavior; unchanged by this slice. |
|
||||||
|
| `alias_retirement_policy` | string | Read-side-only rollout seam for historical aliases. |
|
||||||
|
|
||||||
|
### Validation Rules
|
||||||
|
|
||||||
|
- All new or updated in-scope writes MUST emit `canonical_code` directly.
|
||||||
|
- Unknown values MUST stay explicitly unknown and MUST NOT inherit a nearby canonical label.
|
||||||
|
- Canonical dotted codes that already contain underscore segments remain valid current-release truth and MUST NOT be renamed by this feature.
|
||||||
|
|
||||||
|
### Explicit Canonical Codes That Stay Unchanged
|
||||||
|
|
||||||
|
- `backup_set.update`
|
||||||
|
- `directory.role_definitions.sync`
|
||||||
|
- `tenant.review_pack.generate`
|
||||||
|
- `tenant.evidence.snapshot.generate`
|
||||||
|
- `entra.admin_roles.scan`
|
||||||
|
- `rbac.health_check`
|
||||||
|
|
||||||
|
## Entity: HistoricalOperationTypeAlias
|
||||||
|
|
||||||
|
- **Type**: existing derived compatibility entry from `OperationCatalog`
|
||||||
|
- **Purpose**: maps a legacy raw value such as `inventory_sync` or `baseline_capture` to one canonical operation family for historical reads only.
|
||||||
|
|
||||||
|
### Identity
|
||||||
|
|
||||||
|
- `raw_value` — stored or historical identifier encountered in rows, fixtures, or persisted onboarding drafts
|
||||||
|
|
||||||
|
### Core Fields
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| `raw_value` | string | Historical storage or fixture value. |
|
||||||
|
| `canonical_code` | string | Canonical dotted operation family. |
|
||||||
|
| `alias_status` | string | Existing statuses such as `canonical` or `legacy_alias`. |
|
||||||
|
| `write_allowed` | boolean | `false` for legacy aliases after this feature on touched write paths. |
|
||||||
|
| `retirement_note` | string nullable | Existing contributor-facing retirement guidance. |
|
||||||
|
| `match_surfaces` | array<string> | Bounded to `operation_runs.type` historical rows and onboarding draft state for this slice. |
|
||||||
|
|
||||||
|
### Validation Rules
|
||||||
|
|
||||||
|
- Legacy aliases MAY be resolved only on read paths.
|
||||||
|
- New or updated write-time callers MUST NOT emit legacy aliases.
|
||||||
|
- Alias support MUST remain removable and MUST NOT require dual-write behavior.
|
||||||
|
|
||||||
|
## Entity: OperationRun
|
||||||
|
|
||||||
|
- **Type**: existing persisted model
|
||||||
|
- **Purpose in this feature**: remains the canonical operational record while its `type` field is hardened toward canonical dotted values for new writes and still resolves historical aliases on read.
|
||||||
|
|
||||||
|
### Relevant Fields
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| `type` | string | Existing persisted field; new in-scope writes must use canonical dotted codes. Historical rows may still contain legacy aliases during rollout. |
|
||||||
|
| `run_identity_hash` | string | Dedupe identity; unchanged. |
|
||||||
|
| `context` | json/array | Existing metadata surface; touched summaries and audit-adjacent payloads should emit canonical `operation_type`. |
|
||||||
|
| `summary_counts` | json/array | Existing Ops-UX counters; unchanged. |
|
||||||
|
| `status` | string | Lifecycle remains service-owned and unchanged. |
|
||||||
|
| `outcome` | string | Lifecycle remains service-owned and unchanged. |
|
||||||
|
|
||||||
|
### Relationships
|
||||||
|
|
||||||
|
| Relationship | Target | Purpose |
|
||||||
|
|--------------|--------|---------|
|
||||||
|
| `workspace` | `Workspace` | Keeps workspace isolation explicit. |
|
||||||
|
| `tenant` | `Tenant` | Keeps tenant scope explicit for filters, triage, and onboarding. |
|
||||||
|
| `resolvedOperationType()` | `OperationTypeResolution` | Existing read-path resolution that must remain the only compatibility seam for legacy aliases. |
|
||||||
|
|
||||||
|
### Feature-Specific Invariants
|
||||||
|
|
||||||
|
- `resolvedOperationType()` and `canonicalOperationType()` remain the read-path truth for historical rows.
|
||||||
|
- Touched write owners must stop relying on `OperationRunType::canonicalCode()` as a second-step translation.
|
||||||
|
- Type-specific branches that currently compare raw aliases should compare canonical truth or canonical literals after the write owners converge.
|
||||||
|
|
||||||
|
## Entity: OnboardingBootstrapSelection
|
||||||
|
|
||||||
|
- **Type**: existing persisted workflow state inside `managed_tenant_onboarding_sessions.state`
|
||||||
|
- **Purpose in this feature**: holds selected bootstrap operation types and started bootstrap run references for the onboarding wizard.
|
||||||
|
|
||||||
|
### Relevant Fields
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| `bootstrap_operation_types[]` | array<string> | Load path may encounter legacy aliases; save and start paths must persist canonical dotted codes only after this feature. |
|
||||||
|
| `bootstrap_operation_runs` | map<string,int> | Keys should follow the same canonical operation codes used by `bootstrap_operation_types`. |
|
||||||
|
| `started_operation_type` | string nullable | Audit-adjacent summary field that should emit canonical dotted code for new writes. |
|
||||||
|
|
||||||
|
### Validation Rules
|
||||||
|
|
||||||
|
- Resume behavior may normalize historical aliases.
|
||||||
|
- Persisted selections after any touched save or start action MUST be canonical dotted codes only.
|
||||||
|
- Unknown bootstrap values MUST be dropped or remain explicitly unsupported; they MUST NOT map to a nearby canonical action silently.
|
||||||
|
|
||||||
|
## Entity: ProviderOperationDefinition
|
||||||
|
|
||||||
|
- **Type**: existing shared provider registry definition
|
||||||
|
- **Purpose in this feature**: declares provider-backed operation metadata while consuming canonical platform-owned `operation_type` values.
|
||||||
|
|
||||||
|
### Relevant Fields
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| `operation_type` | string | Must be canonical dotted code after this feature. |
|
||||||
|
| `module` | string | Existing provider module grouping; unchanged. |
|
||||||
|
| `label` | string | Existing operator-facing label; unchanged. |
|
||||||
|
| `required_capability` | string | Existing capability binding; unchanged. |
|
||||||
|
| `provider_binding.provider` | string | Provider-owned runtime binding; unchanged in concept. |
|
||||||
|
|
||||||
|
### Validation Rules
|
||||||
|
|
||||||
|
- Registry definitions and provider bindings MUST reference the same canonical dotted `operation_type`.
|
||||||
|
- Provider binding MUST remain provider-owned, while `operation_type` remains platform-core.
|
||||||
|
- Unsupported combinations still block explicitly; this feature does not weaken start-gate safety.
|
||||||
|
|
||||||
|
## Entity: OperationTypeMetadataPayload
|
||||||
|
|
||||||
|
- **Type**: existing derived metadata shape across audit, triage, alert, and reference contexts
|
||||||
|
- **Purpose in this feature**: ensures operator-adjacent payloads stop copying raw `run->type` as current-release truth.
|
||||||
|
|
||||||
|
### Relevant Fields
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| `operation_type` | string | Should emit canonical dotted code in touched metadata payloads. |
|
||||||
|
| `operation_run_id` | integer | Existing reference to the underlying run. |
|
||||||
|
| `summary_counts` | array | Existing flat metrics; unchanged. |
|
||||||
|
| `scope` / `target_scope` | array or string | Existing scope context; unchanged. |
|
||||||
|
|
||||||
|
### Validation Rules
|
||||||
|
|
||||||
|
- Operator-adjacent metadata MUST not reintroduce legacy raw aliases as first-class truth.
|
||||||
|
- Storage-oriented raw `type` MAY remain in the row itself during rollout, but touched metadata should emit canonical `operation_type`.
|
||||||
|
|
||||||
|
## Relationships
|
||||||
|
|
||||||
|
- One `CanonicalOperationType` may have many `HistoricalOperationTypeAlias` entries.
|
||||||
|
- One `OperationRun` resolves to exactly one `CanonicalOperationType` on read via `OperationCatalog`.
|
||||||
|
- One `OnboardingBootstrapSelection` stores many canonical operation codes and may map each selected canonical code to one run ID.
|
||||||
|
- One `ProviderOperationDefinition` references exactly one `CanonicalOperationType` and may have one active provider binding in the current release.
|
||||||
|
- One `OperationTypeMetadataPayload` should mirror the canonical operation identity for the underlying `OperationRun` without becoming a second source of truth.
|
||||||
|
|
||||||
|
## Rollout / Lifecycle Rules
|
||||||
|
|
||||||
|
### Write-time truth
|
||||||
|
|
||||||
|
- New or updated in-scope writes use canonical dotted `operation_type` values directly.
|
||||||
|
- Legacy aliases are not permitted as new current-release truth on touched writers, registries, config keys, or onboarding persistence.
|
||||||
|
|
||||||
|
### Read-time compatibility
|
||||||
|
|
||||||
|
- Historical `operation_runs.type` values and historical onboarding draft selections may still resolve through the alias map during rollout.
|
||||||
|
- Filter query expansion may continue to use `rawValuesForCanonical()` only where historical rows must still be matched.
|
||||||
|
|
||||||
|
### Unknown handling
|
||||||
|
|
||||||
|
- Unknown values remain explicitly unknown and never auto-normalize to a nearby canonical family.
|
||||||
|
|
||||||
|
### Seam retirement
|
||||||
|
|
||||||
|
- Once historical rows and fixtures are no longer needed, alias entries and onboarding normalization fallbacks can be removed without changing the canonical contract.
|
||||||
|
|
||||||
|
## Persistence Impact
|
||||||
|
|
||||||
|
- **Schema changes**: None
|
||||||
|
- **New tables**: None
|
||||||
|
- **Backfill jobs or migrations**: None planned in this slice
|
||||||
|
- **Config updates**: Existing keys update in place to canonical dotted values where touched
|
||||||
289
specs/239-canonical-operation-type-source-of-truth/plan.md
Normal file
289
specs/239-canonical-operation-type-source-of-truth/plan.md
Normal file
@ -0,0 +1,289 @@
|
|||||||
|
# Implementation Plan: Canonical Operation Type Source of Truth
|
||||||
|
|
||||||
|
**Branch**: `239-canonical-operation-type-source-of-truth` | **Date**: 2026-04-25 | **Spec**: [spec.md](./spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/239-canonical-operation-type-source-of-truth/spec.md`
|
||||||
|
|
||||||
|
**Note**: This plan keeps the slice intentionally tight around one platform-core contract: dotted canonical `operation_type` codes become the only normative truth for touched writes and shared read models. No application code is implemented here.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Promote the existing dotted `OperationCatalog` codes to the single normative `operation_type` contract, converge current write owners such as `OperationRunType`, provider operation definitions, onboarding bootstrap persistence, and lifecycle policy config to emit canonical dotted values directly, and keep only one bounded read-side compatibility seam for historical `operation_runs.type` rows and persisted onboarding draft state. The implementation stays inside the existing catalog, provider-start, onboarding, monitoring, and audit seams, avoids new tables or new abstraction layers, and uses focused unit, feature, Livewire, and architecture coverage to block raw alias drift from returning.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4
|
||||||
|
**Primary Dependencies**: existing `App\Support\OperationCatalog`, `App\Support\OperationRunType`, `App\Services\OperationRunService`, `App\Services\Providers\ProviderOperationRegistry`, `App\Services\Providers\ProviderOperationStartGate`, `App\Filament\Resources\OperationRunResource`, `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`, `App\Support\Filament\FilterOptionCatalog`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\References\Resolvers\OperationRunReferenceResolver`, `App\Services\Audit\AuditEventBuilder`, Pest v4
|
||||||
|
**Storage**: PostgreSQL via existing `operation_runs.type` and `managed_tenant_onboarding_sessions.state->bootstrap_operation_types`, plus config-backed `tenantpilot.operations.lifecycle.covered_types` and `tenantpilot.platform_vocabulary`; no new tables
|
||||||
|
**Testing**: Pest unit, feature, Filament Livewire, and existing heavy-governance tests run through Laravel Sail
|
||||||
|
**Validation Lanes**: `fast-feedback`, `confidence`, `heavy-governance`
|
||||||
|
**Target Platform**: Laravel monolith in `apps/platform` with native Filament v5 admin and system surfaces plus queue-backed `OperationRun` workflows
|
||||||
|
**Project Type**: Monorepo with one Laravel runtime in `apps/platform`; `apps/website` is out of scope
|
||||||
|
**Performance Goals**: keep canonical resolution deterministic and in-process, preserve DB-only monitoring render paths, preserve existing filter/query shape efficiency, and avoid new query fan-out or render-time remote work
|
||||||
|
**Constraints**: no new table, no new abstraction framework, no new operation family, no monitoring IA redesign, no broader governed-subject cleanup outside `operation_type`, no dual-write compatibility path, no change to authorization semantics, and no provider-neutral rename sweep
|
||||||
|
**Scale/Scope**: one existing canonical catalog, one enum-backed contract hotspot, one provider registry/start gate seam, one onboarding bootstrap state seam, one lifecycle config seam, several read-model/presenter consumers, and focused guard coverage
|
||||||
|
|
||||||
|
## Filament v5 Implementation Contract
|
||||||
|
|
||||||
|
- **Livewire v4.0+ compliance**: Preserved. Touched admin and system surfaces remain native Filament v5 / Livewire v4 surfaces and no legacy Livewire or Filament APIs are introduced.
|
||||||
|
- **Provider registration location**: Unchanged. Panel providers remain registered in `apps/platform/bootstrap/providers.php`.
|
||||||
|
- **Global search impact**: No new globally searchable surface is introduced. `OperationRunResource` currently registers no pages (`getPages(): []`), so this slice keeps operation-type hardening outside global search rather than adding View/Edit pages just for searchability.
|
||||||
|
- **Destructive actions**: No new destructive action is planned. Existing destructive actions touched indirectly remain server-authorized and keep `Action::make(...)->action(...)->requiresConfirmation()` where already required; onboarding completion confirmation stays unchanged.
|
||||||
|
- **Asset strategy**: No new assets or panel registrations are planned. Deployment expectations remain unchanged; if later UI work adds registered assets, deploy still runs `cd apps/platform && php artisan filament:assets`.
|
||||||
|
- **Testing plan**: Prove canonical contract convergence with focused unit coverage for resolution or write-truth, focused feature and Livewire coverage for operations filters, onboarding resume/start behavior, tenant-safe detail navigation, and DB-only rendering, plus existing heavy-governance coverage for platform vocabulary and raw-alias or non-leakage drift.
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Plan
|
||||||
|
|
||||||
|
- **Guardrail scope**: changed surfaces
|
||||||
|
- **Native vs custom classification summary**: native Filament surfaces plus existing shared presenters/helpers
|
||||||
|
- **Shared-family relevance**: operations list/detail/filter family, onboarding bootstrap launch family, related-navigation/reference family, audit-adjacent summaries
|
||||||
|
- **State layers in scope**: page, detail, URL-query
|
||||||
|
- **Handling modes by drift class or surface**: raw write-time aliases are `hard-stop-candidate`; read-side compatibility is `review-mandatory`
|
||||||
|
- **Repository-signal treatment**: `future hard-stop candidate`
|
||||||
|
- **Special surface test profiles**: `monitoring-state-page`, `standard-native-filament`, `shared-detail-family`
|
||||||
|
- **Required tests or manual smoke**: `functional-core`, `state-contract`
|
||||||
|
- **Exception path and spread control**: one named read-side compatibility boundary limited to historical `operation_runs.type` rows and persisted onboarding draft state; no write-side exception
|
||||||
|
- **Active feature PR close-out entry**: `Guardrail`
|
||||||
|
|
||||||
|
## Shared Pattern & System Fit
|
||||||
|
|
||||||
|
- **Cross-cutting feature marker**: yes
|
||||||
|
- **Systems touched**: `OperationCatalog`, `OperationRunType`, `ProviderOperationRegistry`, `ProviderOperationStartGate`, `FilterOptionCatalog`, `OperationRunResource`, `ManagedTenantOnboardingWizard`, `OperationRunLinks`, `OperationUxPresenter`, `OperationRunReferenceResolver`, `AuditEventBuilder`, `OperationRunService`, `OperationRunTriageService`, `FindingsLifecycleBackfillRunbookService`, and `tenantpilot` vocabulary/lifecycle config
|
||||||
|
- **Shared abstractions reused**: `OperationCatalog` as the sole canonical contract, `OperationRunService` for run identity and lifecycle, `ProviderOperationStartGate` for shared start UX, `FilterOptionCatalog` for canonical filter options, and existing presenter/reference builders for operator labels
|
||||||
|
- **New abstraction introduced? why?**: none planned. If a tiny helper is needed, it must replace duplicated raw comparisons inside existing seams rather than create a new registry or translator family.
|
||||||
|
- **Why the existing abstraction was sufficient or insufficient**: `OperationCatalog` is already the correct label and canonical-code source, but it is insufficient while enum-backed writes, provider registry definitions, onboarding selections, lifecycle config, and raw type-specific branches still treat aliases as peer truths.
|
||||||
|
- **Bounded deviation / spread control**: compatibility stays read-side only inside the existing catalog and onboarding-normalization path; no dual-write, fallback writer, or long-lived alias preservation layer is allowed.
|
||||||
|
|
||||||
|
## OperationRun UX Impact
|
||||||
|
|
||||||
|
- **Touches OperationRun start/completion/link UX?**: yes
|
||||||
|
- **Central contract reused**: shared OperationRun UX layer via `ProviderOperationStartGate`, `ProviderOperationStartResultPresenter`, `OperationRunService`, and `OperationUxPresenter`
|
||||||
|
- **Delegated UX behaviors**: queued toast, run link, run-enqueued browser event, dedupe-or-scope-busy messaging, queued DB-notification decision, tenant/workspace-safe URL resolution
|
||||||
|
- **Surface-owned behavior kept local**: onboarding keeps only bootstrap selection and provider-connection inputs; operations surfaces remain read-only label/filter consumers
|
||||||
|
- **Queued DB-notification policy**: explicit opt-in, unchanged
|
||||||
|
- **Terminal notification path**: central lifecycle mechanism, unchanged
|
||||||
|
- **Exception path**: none. The only bounded exception is read-side alias compatibility, not a local UX contract bypass.
|
||||||
|
|
||||||
|
## Provider Boundary & Portability Fit
|
||||||
|
|
||||||
|
- **Shared provider/platform boundary touched?**: yes
|
||||||
|
- **Provider-owned seams**: current provider bindings and provider-specific operation families behind `ProviderOperationRegistry` / `ProviderOperationStartGate`, plus provider-specific canonical families such as `directory.groups.sync` and `entra.admin_roles.scan`
|
||||||
|
- **Platform-core seams**: `operation_type` vocabulary, `OperationCatalog`, filter-option convergence, monitoring/read-model summaries, onboarding persisted selection truth, audit metadata, and lifecycle policy config
|
||||||
|
- **Neutral platform terms / contracts preserved**: `operation`, `operation_type`, `canonical operation code`, `operation catalog`, `operation label`
|
||||||
|
- **Retained provider-specific semantics and why**: current dotted provider-owned codes remain valid canonical codes when the operation itself is provider-specific; the slice tightens truth ownership without renaming all provider-domain vocabulary
|
||||||
|
- **Bounded extraction or follow-up path**: `follow-up-spec` for broader governed-subject or provider/domain vocabulary cleanup once `operation_type` truth is stable
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Passed before Phase 0 research. Re-check after Phase 1 design: still passes with one bounded read-side compatibility seam and no new persistence or framework.*
|
||||||
|
|
||||||
|
| Gate | Status | Plan Notes |
|
||||||
|
|------|--------|------------|
|
||||||
|
| Inventory-first / read-write separation | PASS | The feature hardens an existing operational contract. No new mutable workflow beyond canonical identifier replacement is introduced. |
|
||||||
|
| Workspace + tenant isolation / RBAC-UX | PASS | No new route, plane, or capability family is added. Existing `/admin`, tenant-context, and `/system` semantics remain unchanged while filter/read models stay entitlement-safe. |
|
||||||
|
| Run observability / Ops-UX lifecycle | PASS | Existing `OperationRun` creation, dedupe, `summary_counts`, and notification behavior remain service-owned. Only `operation_type` identity entering those paths changes. |
|
||||||
|
| Shared pattern first (XCUT-001) | PASS | The plan reuses `OperationCatalog`, `OperationRunService`, `ProviderOperationStartGate`, and existing presenter/reference helpers rather than introducing a new translation framework. |
|
||||||
|
| Provider boundary (PROV-001) | PASS | Platform-core `operation_type` truth is tightened while provider-specific operation families remain bounded at provider-owned seams. |
|
||||||
|
| Proportionality / no premature abstraction | PASS | The implementation converges on existing catalog/config/service seams and rejects a new registry/resolver layer. |
|
||||||
|
| Persisted truth / behavioral state | PASS | No new table, entity, or status family is added. Existing rows and onboarding drafts remain readable only through the bounded rollout seam. |
|
||||||
|
| LEAN-001 compatibility bias | PASS with explicit spec exception | Pre-production replacement remains the default. The spec explicitly allows only a narrow read-side seam for historical rows and persisted draft state; no write-side preservation is allowed. |
|
||||||
|
| Filament v5 + Livewire v4 contract | PASS | Touched surfaces stay native Filament v5/Livewire v4, panel provider registration remains unchanged, and no new global-search surface is introduced. |
|
||||||
|
| Destructive action safety | PASS | No new destructive action is added. Existing confirmation and authorization requirements remain unchanged. |
|
||||||
|
| Asset strategy | PASS | No asset change is planned. |
|
||||||
|
| Test governance | PASS | Coverage stays in focused unit and feature lanes plus existing heavy-governance families; no browser lane or new heavy-governance family is required for proof. |
|
||||||
|
|
||||||
|
## Test Governance Check
|
||||||
|
|
||||||
|
- **Test purpose / classification by changed surface**: `Unit` for canonical resolution and write-truth enforcement, `Feature` for onboarding and operations filter or detail behavior, `Feature/Livewire` for Filament filter/state flows, `Heavy-Governance` for platform vocabulary and anti-drift or non-leakage guards
|
||||||
|
- **Affected validation lanes**: `fast-feedback`, `confidence`, `heavy-governance`
|
||||||
|
- **Why this lane mix is the narrowest sufficient proof**: the risk is semantic drift at shared contract boundaries, operations-surface entitlement leakage, and accidental render-path regression, not browser rendering. Unit tests prove canonical identity rules and registry writes; focused feature/Livewire tests prove the main operator surfaces, tenant-safe detail navigation, and DB-only rendering; existing heavy-governance guards catch reintroduced raw alias writers and workspace or tenant leakage.
|
||||||
|
- **Narrowest proving commands**:
|
||||||
|
- `Current repo baseline before implementation: export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationTypeResolutionTest.php tests/Unit/Providers/ProviderOperationStartGateTest.php tests/Unit/Onboarding/OnboardingDraftResolverTest.php`
|
||||||
|
- `Post-implementation expanded unit proof after the planned canonical-contract tests land: export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationTypeResolutionTest.php tests/Unit/Support/OperationRunTypeCanonicalContractTest.php tests/Unit/Providers/ProviderOperationRegistryCanonicalTypeTest.php tests/Unit/Providers/ProviderOperationStartGateTest.php tests/Unit/Onboarding/OnboardingDraftResolverTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunListFiltersTest.php tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php tests/Feature/Monitoring/AuditCoverageOperationsTest.php tests/Feature/Monitoring/OperationsTenantScopeTest.php tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php tests/Feature/ManagedTenantOnboardingWizardTest.php tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Architecture/PlatformVocabularyBoundaryGuardTest.php tests/Feature/Guards/OperationRunLinkContractGuardTest.php tests/Feature/OpsUx/UnknownOperationTypeLabelTest.php tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||||
|
- **Fixture / helper / factory / seed / context cost risks**: moderate. Existing factories and helpers default many runs and onboarding drafts to legacy alias strings such as `inventory_sync` or `backup_schedule_run`; the slice must update those defaults deliberately instead of adding a second helper layer.
|
||||||
|
- **Expensive defaults or shared helper growth introduced?**: no; fixture changes should reduce alias drift rather than add compatibility helpers
|
||||||
|
- **Heavy-family additions, promotions, or visibility changes**: existing heavy-governance families in `tests/Architecture` and `tests/Feature/OpsUx` are touched, but no new heavy family is introduced and no browser scope is added
|
||||||
|
- **Surface-class relief / special coverage rule**: `monitoring-state-page` and `standard-native-filament` relief are sufficient; no browser proof is required
|
||||||
|
- **Closing validation and reviewer handoff**: reviewers should confirm that touched writes emit canonical dotted codes, filter options collapse alias duplicates, onboarding resume canonicalizes historical selections, raw-type branches are replaced or deliberately bounded, operations tenant-scope and tenantless detail behavior remain entitlement-safe, DB-only rendering stays DB-only, and compatibility remains read-side only
|
||||||
|
- **Budget / baseline / trend follow-up**: none expected beyond localized fixture updates
|
||||||
|
- **Review-stop questions**: did any touched writer still emit raw aliases? Did any new compatibility path write or preserve aliases? Did the slice widen into broader vocabulary cleanup? Did fixture defaults or guard tests start treating historical aliases as acceptable new truth? Did canonicalization weaken tenant-scope or tenantless-viewer entitlement checks? Did any touched operations surface stop rendering from DB-only state?
|
||||||
|
- **Escalation path**: `document-in-feature`
|
||||||
|
- **Active feature PR close-out entry**: `Guardrail`
|
||||||
|
- **Why no dedicated follow-up spec is needed**: the remaining broader vocabulary cleanup is already out of scope and explicitly deferred; this slice can be proven inside existing lanes.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/239-canonical-operation-type-source-of-truth/
|
||||||
|
├── spec.md
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── contracts/
|
||||||
|
│ └── canonical-operation-type-source-of-truth.logical.openapi.yaml
|
||||||
|
├── checklists/
|
||||||
|
│ └── requirements.md
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root; target touched surfaces after implementation)
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/platform/
|
||||||
|
├── app/
|
||||||
|
│ ├── Filament/
|
||||||
|
│ │ ├── Pages/
|
||||||
|
│ │ │ └── Workspaces/
|
||||||
|
│ │ │ └── ManagedTenantOnboardingWizard.php
|
||||||
|
│ │ └── Resources/
|
||||||
|
│ │ └── OperationRunResource.php
|
||||||
|
│ ├── Models/
|
||||||
|
│ │ └── OperationRun.php
|
||||||
|
│ ├── Services/
|
||||||
|
│ │ ├── Audit/
|
||||||
|
│ │ │ └── AuditEventBuilder.php
|
||||||
|
│ │ ├── Providers/
|
||||||
|
│ │ │ ├── ProviderOperationRegistry.php
|
||||||
|
│ │ │ └── ProviderOperationStartGate.php
|
||||||
|
│ │ ├── Runbooks/
|
||||||
|
│ │ │ └── FindingsLifecycleBackfillRunbookService.php
|
||||||
|
│ │ ├── SystemConsole/
|
||||||
|
│ │ │ └── OperationRunTriageService.php
|
||||||
|
│ │ └── OperationRunService.php
|
||||||
|
│ └── Support/
|
||||||
|
│ ├── Filament/
|
||||||
|
│ │ └── FilterOptionCatalog.php
|
||||||
|
│ ├── OpsUx/
|
||||||
|
│ │ └── OperationUxPresenter.php
|
||||||
|
│ ├── References/Resolvers/
|
||||||
|
│ │ └── OperationRunReferenceResolver.php
|
||||||
|
│ ├── OperationCatalog.php
|
||||||
|
│ ├── OperationRunLinks.php
|
||||||
|
│ └── OperationRunType.php
|
||||||
|
├── config/
|
||||||
|
│ └── tenantpilot.php
|
||||||
|
└── tests/
|
||||||
|
├── Architecture/
|
||||||
|
│ └── PlatformVocabularyBoundaryGuardTest.php
|
||||||
|
├── Feature/
|
||||||
|
│ ├── Filament/
|
||||||
|
│ │ ├── OperationRunListFiltersTest.php
|
||||||
|
│ │ └── RecentOperationsSummaryWidgetTest.php
|
||||||
|
│ ├── Guards/
|
||||||
|
│ │ └── OperationRunLinkContractGuardTest.php
|
||||||
|
│ ├── ManagedTenantOnboardingWizardTest.php
|
||||||
|
│ └── Workspaces/
|
||||||
|
│ └── ManagedTenantOnboardingProviderStartTest.php
|
||||||
|
└── Unit/
|
||||||
|
├── Providers/
|
||||||
|
│ ├── ProviderOperationRegistryCanonicalTypeTest.php
|
||||||
|
│ └── ProviderOperationStartGateTest.php
|
||||||
|
└── Support/
|
||||||
|
├── OperationRunTypeCanonicalContractTest.php
|
||||||
|
└── OperationTypeResolutionTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Additional concrete seams intentionally included by the task plan inside this same boundary are `apps/platform/app/Support/Operations/OperationLifecyclePolicy.php`, `apps/platform/app/Services/Evidence/Sources/OperationsSummarySource.php`, `apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php`, `apps/platform/app/Http/Controllers/AdminConsentCallbackController.php`, `apps/platform/app/Services/Inventory/InventorySyncService.php`, `apps/platform/app/Services/BackupScheduling/BackupScheduleDispatcher.php`, `apps/platform/app/Services/ReviewPackService.php`, `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php`, and `apps/platform/app/Services/TenantReviews/TenantReviewService.php`.
|
||||||
|
|
||||||
|
**Structure Decision**: keep the slice entirely inside the existing Laravel runtime in `apps/platform`, extending current operation-support, provider-start, onboarding, and monitoring seams. No new top-level codebase area or secondary framework is needed.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
No constitutional violation is planned. One bounded complexity concern is tracked because the feature explicitly preserves a temporary compatibility seam despite LEAN-001’s default replacement bias.
|
||||||
|
|
||||||
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|
|-----------|------------|-------------------------------------|
|
||||||
|
| Bounded read-side compatibility seam for historical `operation_runs.type` rows and onboarding draft arrays | Existing rows and persisted drafts already hold aliases that the spec requires to remain readable during rollout, but only on read paths | Mass rewriting historical rows and draft payloads inside this slice would broaden scope into data migration/cleanup; write-side preservation or dual-write is forbidden |
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
- **Current operator problem**: the same operation family is emitted, stored, filtered, audited, and reviewed under different identifiers, which weakens operator trust and lets new drift re-enter shared seams.
|
||||||
|
- **Existing structure is insufficient because**: `OperationCatalog` already knows the canonical dotted contract, but `OperationRunType`, provider registry definitions, onboarding selections, lifecycle config, and raw branch logic still treat aliases as first-class truth.
|
||||||
|
- **Narrowest correct implementation**: make the existing catalog the only normative contract, change touched writers to emit canonical dotted codes directly, and limit compatibility to read-time alias resolution for historical rows and onboarding draft state.
|
||||||
|
- **Ownership cost created**: several focused code paths and tests must be updated together, and the bounded alias map needs explicit retirement review instead of silently becoming permanent.
|
||||||
|
- **Alternative intentionally rejected**: keeping long-lived dual semantics via `canonicalCode()` or adding a broader operation-type resolver framework. Both preserve drift and add maintenance cost without solving the underlying truth split.
|
||||||
|
- **Release truth**: current-release anti-drift hardening for a platform-core canonical noun
|
||||||
|
|
||||||
|
## Phase 0 Research Summary
|
||||||
|
|
||||||
|
- `OperationCatalog` is already the correct canonical authority; `OperationRunType::canonicalCode()` is now evidence of drift, not the desired steady-state API.
|
||||||
|
- The first-slice write owners are `OperationRunType`, `ProviderOperationRegistry` / `ProviderOperationStartGate`, onboarding bootstrap selection persistence/start, and `tenantpilot.operations.lifecycle.covered_types`.
|
||||||
|
- Raw type branches still exist in `OperationRunResource`, `OperationRunLinks`, and non-UI metadata emitters such as `OperationRunService`, `OperationRunTriageService`, and `FindingsLifecycleBackfillRunbookService`.
|
||||||
|
- Canonical dotted codes that legitimately retain underscore segments and must not trigger broader cleanup include `backup_set.update`, `directory.role_definitions.sync`, `tenant.review_pack.generate`, `tenant.evidence.snapshot.generate`, `entra.admin_roles.scan`, and `rbac.health_check`.
|
||||||
|
- The only allowed compatibility seam is read-side alias resolution for historical rows and persisted onboarding drafts.
|
||||||
|
- Focused unit, feature, Livewire, and existing heavy-governance coverage is sufficient; browser proof is not required for this slice.
|
||||||
|
|
||||||
|
## Phase 1 Design Summary
|
||||||
|
|
||||||
|
- `research.md` records contract decisions, the explicit underscore-segment exceptions that remain canonical, and the non-UI metadata sites that still surface raw `type`.
|
||||||
|
- `data-model.md` defines the canonical operation-type contract, legacy alias seam, onboarding bootstrap selection truth, and provider operation definition requirements.
|
||||||
|
- `contracts/canonical-operation-type-source-of-truth.logical.openapi.yaml` records the logical contract for resolving raw values, normalizing onboarding selections, and reading canonical filter/write metadata.
|
||||||
|
- `quickstart.md` captures the narrow implementation order and review-proof commands.
|
||||||
|
- `tasks.md` remains Phase 2 work and is not created by `/speckit.plan`.
|
||||||
|
|
||||||
|
## Phase 1 — Agent Context Update
|
||||||
|
|
||||||
|
Run after artifact generation:
|
||||||
|
|
||||||
|
- `.specify/scripts/bash/update-agent-context.sh copilot`
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### Phase A — Collapse write-time truth onto canonical dotted codes
|
||||||
|
|
||||||
|
**Goal**: remove peer write truths before touching broader read surfaces.
|
||||||
|
|
||||||
|
- Update `app/Support/OperationRunType.php` so enum-backed producers stop relying on `canonicalCode()` translation and instead represent canonical dotted values directly or become a pure compatibility shim slated for removal.
|
||||||
|
- Align `app/Services/Providers/ProviderOperationRegistry.php`, `ProviderOperationStartGate.php`, and onboarding/bootstrap capability resolution in `ManagedTenantOnboardingWizard.php` to use canonical dotted `operation_type` values as their emitted and persisted contract.
|
||||||
|
- Convert `apps/platform/config/tenantpilot.php` operation lifecycle `covered_types` keys to canonical dotted codes and keep any historical storage compatibility bounded to read/lookup paths.
|
||||||
|
- Sweep the first-slice service/job start owners that currently use `OperationRunType::...->value` or raw aliases and make their emitted run type canonical at the source.
|
||||||
|
|
||||||
|
### Phase B — Bound compatibility to read-time resolution only
|
||||||
|
|
||||||
|
**Goal**: keep historical rows and draft state readable without preserving legacy write behavior.
|
||||||
|
|
||||||
|
- Keep alias resolution centralized in `OperationCatalog` and trim the alias inventory to explicitly read-side cases only.
|
||||||
|
- Normalize historical onboarding draft `bootstrap_operation_types` on load, save, and start so resumed drafts stop writing aliases back into session state.
|
||||||
|
- Ensure unknown or unsupported operation values remain explicitly unknown and never inherit nearby canonical labels.
|
||||||
|
- Do not add backfill migrations, dual-write logic, or fallback writers.
|
||||||
|
|
||||||
|
### Phase C — Converge read models, filters, and operator-adjacent metadata
|
||||||
|
|
||||||
|
**Goal**: make all touched consumers read through one canonical contract.
|
||||||
|
|
||||||
|
- Replace raw type comparisons in `OperationRunResource`, `OperationRunLinks`, and other touched type-specific branches with canonical checks or canonical value literals.
|
||||||
|
- Keep `FilterOptionCatalog`, `OperationUxPresenter`, `OperationRunReferenceResolver`, and `AuditEventBuilder` on `OperationCatalog` resolution, and update any remaining raw-type assumptions they rely on.
|
||||||
|
- Canonicalize operator-adjacent metadata payloads in `OperationRunService`, `OperationRunTriageService`, `FindingsLifecycleBackfillRunbookService`, and onboarding audit metadata where `operation_type` is currently copied from raw storage.
|
||||||
|
- Preserve query efficiency by using `OperationCatalog::rawValuesForCanonical()` only where historical storage rows still need to be matched.
|
||||||
|
|
||||||
|
### Phase D — Lock the boundary with focused tests and guards
|
||||||
|
|
||||||
|
**Goal**: make raw alias reintroduction fail fast.
|
||||||
|
|
||||||
|
- Extend unit coverage for canonical resolution and add guard coverage for enum/registry canonical contract behavior.
|
||||||
|
- Extend operations filter and onboarding start/resume feature coverage so one canonical selection maps to both current and historical rows without leaking cross-tenant data.
|
||||||
|
- Tighten heavy-governance guard tests so new in-scope alias writers, tenant-leakage paths, or registry entries fail review and CI.
|
||||||
|
- Update shared fixture defaults only where necessary to stop teaching aliases as normal current-release truth.
|
||||||
|
|
||||||
|
## Risks and Mitigations
|
||||||
|
|
||||||
|
- **Scope creep into broader vocabulary cleanup**: mitigate by explicitly preserving already-canonical underscore-segment codes and keeping all non-`operation_type` naming outside this spec.
|
||||||
|
- **Compatibility seam persistence**: mitigate by restricting alias handling to catalog and onboarding read normalization and rejecting any write-side fallback.
|
||||||
|
- **Partial convergence**: mitigate by sequencing write owners before read-model cleanup and using guard coverage for registry/start paths.
|
||||||
|
- **Fixture churn**: mitigate by updating only the affected factories/helpers and avoiding a second compatibility helper layer.
|
||||||
|
|
||||||
|
## Post-Design Re-check
|
||||||
|
|
||||||
|
The feature remains constitution-compliant and implementation-ready. It introduces no new table, no new abstraction framework, no new operation family, no monitoring IA redesign, no provider-platform boundary rewrite beyond the in-scope contract, and no Filament panel or asset change. The plan keeps compatibility explicitly bounded and read-side only, preserves Livewire v4 / Filament v5 conventions, keeps provider registration in `bootstrap/providers.php`, leaves global search unchanged, and centers proof on focused unit, feature, Livewire, and existing heavy-governance tests.
|
||||||
|
|
||||||
|
## Implementation Close-Out
|
||||||
|
|
||||||
|
- **Guardrail disposition**: `Guardrail` remains the active close-out entry. The implementation stayed inside `operation_type` truth and did not add a generic compatibility framework, new persistence, panel changes, assets, or global-search changes.
|
||||||
|
- **Compatibility disposition**: `document-in-feature`. Historical alias handling remains bounded to read-side resolution/filter matching, queued-run reauthorization, and onboarding draft normalization. No writer may use a legacy alias as registry or persisted current-release truth.
|
||||||
|
- **Deferred boundary**: `follow-up-spec` remains appropriate only for broader provider/domain vocabulary cleanup outside this slice, such as future governed-subject naming work.
|
||||||
|
- **Validation recorded**: focused US1, US2, US3, and affected writer/queue regression lanes ran through Sail/Pest before final validation. Final handoff requires the full quickstart command in `quickstart.md` plus dirty-file Pint.
|
||||||
127
specs/239-canonical-operation-type-source-of-truth/quickstart.md
Normal file
127
specs/239-canonical-operation-type-source-of-truth/quickstart.md
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
# Quickstart: Canonical Operation Type Source of Truth
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make dotted canonical `operation_type` codes the single normative platform contract for the touched slice, while keeping only a bounded read-side compatibility seam for historical `operation_runs.type` rows and persisted onboarding draft state.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. Start the local stack:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Work on branch `239-canonical-operation-type-source-of-truth`.
|
||||||
|
3. Keep the slice tightly bounded to `operation_type` truth only. Do not widen into broader governed-subject cleanup, monitoring IA changes, or historical data backfill.
|
||||||
|
|
||||||
|
## Implementation Sequence
|
||||||
|
|
||||||
|
1. Normalize the core contract first:
|
||||||
|
- `app/Support/OperationCatalog.php`
|
||||||
|
- `app/Support/OperationRunType.php`
|
||||||
|
- `config/tenantpilot.php`
|
||||||
|
Preserve already-canonical dotted codes that still contain underscore segments, including `backup_set.update`, `directory.role_definitions.sync`, `tenant.review_pack.generate`, `tenant.evidence.snapshot.generate`, `entra.admin_roles.scan`, and `rbac.health_check`.
|
||||||
|
2. Converge the first-slice write owners:
|
||||||
|
- `app/Services/Providers/ProviderOperationRegistry.php`
|
||||||
|
- `app/Services/Providers/ProviderOperationStartGate.php`
|
||||||
|
- `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
|
||||||
|
- any touched service or job start sites still emitting `OperationRunType::...->value` or raw aliases
|
||||||
|
3. Bound compatibility to read time only:
|
||||||
|
- keep alias resolution centralized in `OperationCatalog`
|
||||||
|
- normalize onboarding draft `bootstrap_operation_types` on load, save, and start
|
||||||
|
- do not add dual-write, fallback writers, or data backfill
|
||||||
|
4. Converge read models, links, and audit-adjacent metadata:
|
||||||
|
- `app/Filament/Resources/OperationRunResource.php`
|
||||||
|
- `app/Support/Filament/FilterOptionCatalog.php`
|
||||||
|
- `app/Support/OpsUx/OperationUxPresenter.php`
|
||||||
|
- `app/Support/References/Resolvers/OperationRunReferenceResolver.php`
|
||||||
|
- `app/Support/OperationRunLinks.php`
|
||||||
|
- `app/Services/Audit/AuditEventBuilder.php`
|
||||||
|
- `app/Services/OperationRunService.php`
|
||||||
|
- `app/Services/SystemConsole/OperationRunTriageService.php`
|
||||||
|
- `app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php`
|
||||||
|
5. Tighten tests and fixture defaults so new current-release writes stop teaching aliases as normal truth.
|
||||||
|
|
||||||
|
Additional concrete seams explicitly called out by the task plan within this same slice:
|
||||||
|
- `app/Support/Operations/OperationLifecyclePolicy.php`
|
||||||
|
- `app/Services/Evidence/Sources/OperationsSummarySource.php`
|
||||||
|
- `app/Services/Onboarding/OnboardingLifecycleService.php`
|
||||||
|
- `app/Http/Controllers/AdminConsentCallbackController.php`
|
||||||
|
- `app/Services/Inventory/InventorySyncService.php`
|
||||||
|
- `app/Services/BackupScheduling/BackupScheduleDispatcher.php`
|
||||||
|
- `app/Services/ReviewPackService.php`
|
||||||
|
- `app/Services/Evidence/EvidenceSnapshotService.php`
|
||||||
|
- `app/Services/TenantReviews/TenantReviewService.php`
|
||||||
|
|
||||||
|
## Tests To Update Or Add
|
||||||
|
|
||||||
|
1. Canonical resolution and write-truth unit coverage:
|
||||||
|
- `tests/Unit/Support/OperationTypeResolutionTest.php`
|
||||||
|
- `tests/Unit/Support/OperationRunTypeCanonicalContractTest.php`
|
||||||
|
- `tests/Unit/Providers/ProviderOperationRegistryCanonicalTypeTest.php`
|
||||||
|
- `tests/Unit/Providers/ProviderOperationStartGateTest.php`
|
||||||
|
2. Operations filters and onboarding feature coverage:
|
||||||
|
- `tests/Feature/Filament/OperationRunListFiltersTest.php`
|
||||||
|
- `tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php`
|
||||||
|
- `tests/Feature/Monitoring/AuditCoverageOperationsTest.php`
|
||||||
|
- `tests/Feature/Monitoring/OperationsTenantScopeTest.php`
|
||||||
|
- `tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php`
|
||||||
|
- `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`
|
||||||
|
- `tests/Feature/ManagedTenantOnboardingWizardTest.php`
|
||||||
|
- `tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php`
|
||||||
|
- `tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php`
|
||||||
|
- `tests/Feature/Guards/OperationRunLinkContractGuardTest.php`
|
||||||
|
3. Anti-drift heavy-governance coverage:
|
||||||
|
- `tests/Architecture/PlatformVocabularyBoundaryGuardTest.php`
|
||||||
|
- `tests/Feature/OpsUx/UnknownOperationTypeLabelTest.php`
|
||||||
|
- `tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php`
|
||||||
|
|
||||||
|
## Focused Verification
|
||||||
|
|
||||||
|
If you are reviewing the artifact set before implementation, start with the current repo baseline:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationTypeResolutionTest.php tests/Unit/Providers/ProviderOperationStartGateTest.php tests/Unit/Onboarding/OnboardingDraftResolverTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
After the planned canonical-contract tests land, run the expanded narrow proving lane:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationTypeResolutionTest.php tests/Unit/Support/OperationRunTypeCanonicalContractTest.php tests/Unit/Providers/ProviderOperationRegistryCanonicalTypeTest.php tests/Unit/Providers/ProviderOperationStartGateTest.php tests/Unit/Onboarding/OnboardingDraftResolverTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run the representative operator-surface proof:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunListFiltersTest.php tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php tests/Feature/Monitoring/AuditCoverageOperationsTest.php tests/Feature/Monitoring/OperationsTenantScopeTest.php tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php tests/Feature/ManagedTenantOnboardingWizardTest.php tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run the guardrail proof that keeps `operation_type` platform-core, blocks raw alias drift, and preserves workspace or tenant non-leakage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Architecture/PlatformVocabularyBoundaryGuardTest.php tests/Feature/Guards/OperationRunLinkContractGuardTest.php tests/Feature/OpsUx/UnknownOperationTypeLabelTest.php tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
If PHP files were changed, finish with formatting:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
||||||
|
```
|
||||||
|
|
||||||
|
## Review Focus
|
||||||
|
|
||||||
|
1. Confirm touched writers emit canonical dotted codes directly and do not rely on `canonicalCode()` as a second translation step.
|
||||||
|
2. Confirm filter options collapse alias variants into one canonical selection while still matching historical rows where required.
|
||||||
|
3. Confirm onboarding resume, save, and start behavior normalizes historical bootstrap selections and persists canonical codes only.
|
||||||
|
4. Confirm operations tenant-scope behavior, tenantless detail navigation, related-run links, summary widgets, and non-leakage guarantees remain intact after canonicalization.
|
||||||
|
5. Confirm DB-only monitoring render paths remain DB-only and do not introduce new query fan-out or render-time remote work.
|
||||||
|
6. Confirm audit-adjacent metadata, triage payloads, and runbook alerts stop copying raw `run->type` as current-release `operation_type` truth.
|
||||||
|
7. Confirm no new compatibility writer, no new table, no broader vocabulary cleanup, and no new asset or global-search change slipped into the slice.
|
||||||
|
|
||||||
|
## Guardrail Close-Out
|
||||||
|
|
||||||
|
- Validation before handoff should show that write-time truth is canonical, read-time compatibility is bounded, and raw alias drift is blocked by tests rather than reviewer memory alone.
|
||||||
|
- The close-out decision for this feature should remain `Guardrail` unless implementation expands into broader vocabulary or migration work, in which case the slice should be split.
|
||||||
|
- Implementation close-out keeps the bounded compatibility seam as `document-in-feature`: historical `operation_runs.type` aliases, queued-run reauthorization, filter matching, and persisted onboarding draft values are normalized on read, while provider registry/start paths and current-release writers reject or avoid legacy aliases.
|
||||||
|
- Broader provider/domain vocabulary cleanup remains a `follow-up-spec` boundary and is not part of this slice.
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
# Research: Canonical Operation Type Source of Truth
|
||||||
|
|
||||||
|
## Decision 1: Promote `OperationCatalog` from canonical read helper to sole normative contract
|
||||||
|
|
||||||
|
- **Decision**: Treat the dotted definitions in `App\Support\OperationCatalog` as the single platform-owned `operation_type` contract for the first implementation slice, and converge write owners on those values directly.
|
||||||
|
- **Rationale**: Repo reads show `OperationCatalog` already owns canonical dotted codes, labels, alias retirement metadata, and filter convergence, while `OperationRunType`, provider registry definitions, onboarding state, and lifecycle config still emit legacy aliases. Keeping `canonicalCode()` as a required second step preserves the dual-truth problem instead of removing it.
|
||||||
|
- **Alternatives considered**: Keep the current dual-semantics model where enum or registry values remain legacy aliases and callers translate later via `canonicalCode()`. Rejected because it continues to teach the wrong contract and keeps every new caller responsible for remembering the translation step.
|
||||||
|
|
||||||
|
## Decision 2: Keep compatibility explicitly bounded and read-side only
|
||||||
|
|
||||||
|
- **Decision**: The only allowed compatibility seam is read-time alias resolution for historical `operation_runs.type` rows and persisted onboarding draft state already present during rollout.
|
||||||
|
- **Rationale**: The spec explicitly requires historical readability, and repo truth shows legacy aliases still exist in stored rows and onboarding session state. Existing `OperationCatalog::resolve()` and onboarding normalization are the narrowest places to keep that seam without preserving aliases as current truth.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Rewrite all historical rows and draft payloads as part of this slice. Rejected because it broadens the work into data backfill and migration cleanup outside the requested scope.
|
||||||
|
- Add dual-write or fallback writers. Rejected by the spec and by LEAN-001 because it would make the drift permanent.
|
||||||
|
|
||||||
|
## Decision 3: First-slice write owners are concrete and already visible in repo hotspots
|
||||||
|
|
||||||
|
- **Decision**: The first implementation pass should converge these concrete write owners before widening into additional consumers: `OperationRunType`, `ProviderOperationRegistry`, `ProviderOperationStartGate`, `ManagedTenantOnboardingWizard`, and `tenantpilot.operations.lifecycle.covered_types`.
|
||||||
|
- **Rationale**: Repo reads show each of these currently emits or persists raw aliases such as `inventory_sync`, `baseline_capture`, `entra_group_sync`, or `backup_schedule_run`. If they remain unchanged, touched read models will keep absorbing drift forever.
|
||||||
|
- **Alternatives considered**: Start by patching only list labels and filter options. Rejected because read-only cleanup would leave onboarding, provider dispatch, and lifecycle policy config still writing legacy values.
|
||||||
|
|
||||||
|
## Decision 4: Raw type-specific branches and operator-adjacent metadata are part of the first pass
|
||||||
|
|
||||||
|
- **Decision**: Replace or bound raw `type` comparisons and raw `operation_type` metadata copies in touched consumers such as `OperationRunResource`, `OperationRunLinks`, `OperationRunService`, `OperationRunTriageService`, and `FindingsLifecycleBackfillRunbookService`.
|
||||||
|
- **Rationale**: The repo already resolves labels through `OperationCatalog`, but several branches still compare against raw aliases like `baseline_compare`, `baseline_capture`, and `inventory_sync`, and several metadata payloads still emit `(string) $run->type` directly. Leaving those sites untouched would keep a hidden second truth even after primary writes are fixed.
|
||||||
|
- **Alternatives considered**: Limit scope to visible labels only. Rejected because audit-adjacent summaries, system triage metadata, and onboarding audit payloads are operator-facing evidence paths too.
|
||||||
|
|
||||||
|
## Decision 5: Canonical dotted codes with underscore segments remain canonical and unchanged
|
||||||
|
|
||||||
|
- **Decision**: Preserve existing dotted canonical codes that already contain underscore segments and explicitly treat them as in-bounds current-release truth, not cleanup debt for this spec.
|
||||||
|
- **Rationale**: Repo truth in `OperationCatalog` shows current canonical entries such as `backup_set.update`, `directory.role_definitions.sync`, `tenant.review_pack.generate`, `tenant.evidence.snapshot.generate`, `entra.admin_roles.scan`, and `rbac.health_check`. The spec explicitly forbids widening this slice into cosmetic segment renaming.
|
||||||
|
- **Alternatives considered**: Rename all embedded underscore segments while the contract is being hardened. Rejected because that turns one contract-hardening spec into a broader vocabulary rewrite.
|
||||||
|
|
||||||
|
## Decision 6: Non-UI exports and summaries that still surface raw `type` must be called out explicitly
|
||||||
|
|
||||||
|
- **Decision**: Treat the following non-UI or audit-adjacent payload sites as in-scope planning targets for canonical `operation_type` emission: `OperationRunService` audit recorder metadata, `OperationRunTriageService` triage audit metadata, `FindingsLifecycleBackfillRunbookService` alert metadata, and onboarding audit metadata (`operation_types` and `started_operation_type`).
|
||||||
|
- **Rationale**: The spec asked whether non-UI summaries still surface raw `operation_runs.type`. Repo reads confirm that they do, and these payloads influence operator and reviewer understanding even when they are not rendered in the primary table surface.
|
||||||
|
- **Alternatives considered**: Leave those payloads out of scope as “not UI.” Rejected because the feature is about one platform contract across monitoring, onboarding, references, and audit-adjacent summaries, not just visible table labels.
|
||||||
|
|
||||||
|
## Decision 7: Keep proof in focused unit, feature, Livewire, and architecture lanes
|
||||||
|
|
||||||
|
- **Decision**: Use focused unit coverage for canonical resolution and registry truth, focused feature and Livewire coverage for onboarding and operations filters, and architecture guard coverage for vocabulary drift. Do not require browser coverage for proof.
|
||||||
|
- **Rationale**: The repo hotspots and the spec show a shared-contract problem, not a browser-specific interaction problem. Existing tests already cover key surfaces such as `OperationTypeResolutionTest`, `OperationRunListFiltersTest`, `ManagedTenantOnboardingWizardTest`, `ManagedTenantOnboardingProviderStartTest`, and `PlatformVocabularyBoundaryGuardTest`.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Browser proof for onboarding resume. Rejected as unnecessary for the first proving lane.
|
||||||
|
- Repo-wide grep bans for legacy aliases. Rejected because historical fixtures and read-side compatibility remain intentionally bounded and a blind string-ban would either fail valid cases or encourage exception lists.
|
||||||
359
specs/239-canonical-operation-type-source-of-truth/spec.md
Normal file
359
specs/239-canonical-operation-type-source-of-truth/spec.md
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
# Feature Specification: Canonical Operation Type Source of Truth
|
||||||
|
|
||||||
|
**Feature Branch**: `239-canonical-operation-type-source-of-truth`
|
||||||
|
**Created**: 2026-04-25
|
||||||
|
**Status**: Implemented
|
||||||
|
**Input**: User description: "Canonical Operation Type Source of Truth"
|
||||||
|
**Mode**: Implementation close-out. This artifact now records the scoped contract, guardrails, acceptance criteria, and validation basis for the implemented slice.
|
||||||
|
|
||||||
|
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
||||||
|
|
||||||
|
- **Problem**: The repo still has two competing truths for the same platform-owned operation identity: canonical dotted `operation_type` codes in the catalog and multiple raw storage or enum values such as `inventory_sync`, `baseline_capture`, `entra_group_sync`, and `backup_schedule_run` in in-scope writers and persisted workflow state.
|
||||||
|
- **Today's failure**: The same operation family can be started, persisted, filtered, audited, or reviewed under different identifiers, which teaches the wrong platform contract, makes rollout guards weaker, and lets monitoring, onboarding, provider dispatch, and read models drift apart.
|
||||||
|
- **User-visible improvement**: Operators and reviewers see one consistent operation identity across monitoring, onboarding, reporting, filters, and audit-adjacent surfaces, while historical rows continue to resolve correctly during the bounded rollout seam.
|
||||||
|
- **Smallest enterprise-capable version**: Promote the existing dotted catalog codes to the single normative `operation_type` contract, converge the current write owners around that contract, and allow only a narrowly bounded read-side compatibility seam for historical rows and draft state encountered during rollout.
|
||||||
|
- **Architecture center**: The primary deliverable is one platform-owned `operation_type` contract. Surface consistency, filter convergence, and drift guards are acceptance evidence for that contract, not a justification for new framework layers.
|
||||||
|
- **Explicit non-goals**: No new tables, no new operation family, no monitoring information-architecture redesign, no broad governed-subject vocabulary cleanup, no provider-neutral rewrite of every domain-specific operation name, and no long-lived dual-write or alias-preservation framework.
|
||||||
|
- **Permanent complexity imported**: One explicit single-source contract for `operation_type`, one temporary read-side compatibility seam during rollout, and focused guard coverage that blocks new raw alias writers from reappearing.
|
||||||
|
- **Why now**: This is the next unresolved anti-drift hotspot in the active governance hardening lane after Specs 237 and 238. The repo already classifies `operation_type` as a platform-core canonical noun, and later governed-subject vocabulary work depends on stabilizing this contract first.
|
||||||
|
- **Why not local**: The inconsistency is not isolated to one label or page. It crosses enum-backed writes, provider operation definitions, onboarding bootstrap workflow state, operation run read models, filter catalogs, audit metadata, and supporting guard tests.
|
||||||
|
- **Approval class**: Core Enterprise
|
||||||
|
- **Red flags triggered**: Foundation-sounding scope, many affected surfaces, and contract hardening in a shared platform seam. Defense: the slice stays tightly bounded to one already-existing contract, introduces no new persistence or framework, and prefers replacement over compatibility scaffolding.
|
||||||
|
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 1 | Wiederverwendung: 2 | **Gesamt: 10/12**
|
||||||
|
- **Decision**: approve
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: workspace, tenant, canonical-view, platform
|
||||||
|
- **Primary Routes**:
|
||||||
|
- Existing workspace-admin Operations collection and drill-in surfaces anchored on `/admin/operations`
|
||||||
|
- Existing workspace-admin onboarding flow at `/admin/onboarding`
|
||||||
|
- Existing system Operations pages at `/system/ops/runs`, `/system/ops/failures`, `/system/ops/stuck`, and `/system/ops/runs/{run}`
|
||||||
|
- Existing shared widgets, related-navigation links, and audit-adjacent summaries that present operation identity on those surfaces
|
||||||
|
- **Data Ownership**:
|
||||||
|
- `operation_runs` remain the canonical persisted operational record; this spec does not add a new persisted run artifact
|
||||||
|
- `managed_tenant_onboarding_sessions.state.bootstrap_operation_types` remains existing workflow state and does not become a new source of truth
|
||||||
|
- `OperationCatalog`, filter options, labels, audit/reference summaries, and run presenters remain derived platform-core read models over canonical operation identity
|
||||||
|
- **RBAC**:
|
||||||
|
- Existing workspace membership remains required for workspace-admin operations and onboarding surfaces
|
||||||
|
- Existing tenant entitlement remains required for tenant-context onboarding behavior and any tenant-owned operation visibility
|
||||||
|
- Existing platform-user authorization remains required for `/system/ops/*` surfaces
|
||||||
|
- Existing operation-start capabilities remain authoritative; this spec introduces no new capability and does not change 404 versus 403 semantics
|
||||||
|
|
||||||
|
For canonical-view specs, the spec MUST define:
|
||||||
|
|
||||||
|
- **Default filter behavior when tenant-context is active**: Covered admin operations and monitoring surfaces continue to prefilter to the active tenant when tenant-context is present, and their operation-type filters expose only canonical dotted choices rather than duplicate alias variants.
|
||||||
|
- **Explicit entitlement checks preventing cross-tenant leakage**: Existing workspace and tenant entitlement checks remain authoritative before any operation label, filter result, or drill-in is revealed. Canonicalization must not create a new path that reveals inaccessible tenant-owned runs through filter options, counts, or read-model summaries.
|
||||||
|
|
||||||
|
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
|
||||||
|
|
||||||
|
- **Cross-cutting feature?**: yes
|
||||||
|
- **Interaction class(es)**: operation labels, filter options, launch-surface selections, audit metadata, related-navigation summaries, run detail headings, and monitoring summaries
|
||||||
|
- **Systems touched**: `OperationCatalog`, `OperationRunType`, provider operation definitions and start-gate payloads, onboarding bootstrap selection and resume state, filter option catalogs, operations list/detail surfaces, system ops pages, operation references, audit summaries, and workspace/dashboard summaries
|
||||||
|
- **Existing pattern(s) to extend**: the existing `OperationCatalog` canonical definitions and alias inventory, `FilterOptionCatalog::operationTypes()`, `OperationUxPresenter`, `OperationRunService`, and the current `operation_type` glossary entry in the platform vocabulary inventory
|
||||||
|
- **Shared contract / presenter / builder / renderer to reuse**: `OperationCatalog` remains the sole shared contract for canonical operation identity, labels, alias retirement metadata, and filter-option convergence; existing presenters and surfaces continue to read through that path instead of inventing a second translation layer
|
||||||
|
- **Why the existing shared path is sufficient or insufficient**: the existing shared path is sufficient as the canonical catalog and operator-label source, but it is insufficient as long as enum-backed writers, provider operation definitions, and onboarding state still emit raw aliases as peer truths
|
||||||
|
- **Allowed deviation and why**: a narrowly bounded read-side compatibility seam is allowed only for historical `operation_runs.type` rows and persisted onboarding draft state encountered during rollout; no new write-side aliasing, dual-write, or long-lived preservation path is allowed
|
||||||
|
- **Consistency impact**: monitoring filters, run labels, audit metadata, onboarding bootstrap state, provider operation definitions, reference summaries, and start-gate payloads must all agree on the same canonical dotted `operation_type`
|
||||||
|
- **Review focus**: reviewers must block any new underscore-style or alternate raw identifier from becoming write-time or registry-time truth, and must verify that compatibility stays read-side only and explicitly temporary
|
||||||
|
|
||||||
|
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
|
||||||
|
|
||||||
|
- **Touches OperationRun start/completion/link UX?**: yes
|
||||||
|
- **Shared OperationRun UX contract/layer reused**: the existing shared start and label path owned by `OperationRunService`, `OperationUxPresenter`, and `OperationCatalog`
|
||||||
|
- **Delegated start/completion UX behaviors**: queued toast, `Open operation` deep link, run-enqueued browser event, queued DB-notification decision, dedupe-or-blocked messaging, and tenant-safe run URL resolution remain delegated to the existing shared OperationRun UX path; this slice changes the operation identity it receives, not the UX ownership model
|
||||||
|
- **Local surface-owned behavior that remains**: launch surfaces such as the onboarding bootstrap step keep only initiation inputs and selection state; they do not own parallel operation-type translation rules
|
||||||
|
- **Queued DB-notification policy**: existing explicit opt-in only; unchanged
|
||||||
|
- **Terminal notification path**: existing central lifecycle mechanism; unchanged
|
||||||
|
- **Exception required?**: none
|
||||||
|
|
||||||
|
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
|
||||||
|
|
||||||
|
- **Shared provider/platform boundary touched?**: yes
|
||||||
|
- **Boundary classification**: mixed
|
||||||
|
- **Seams affected**: platform-core `operation_type` vocabulary, provider operation registry definitions and bindings, operation start-gate payloads, monitoring and reporting read models, audit metadata, and onboarding bootstrap selections
|
||||||
|
- **Neutral platform terms preserved or introduced**: `operation`, `operation_type`, canonical operation code, operation catalog, operation label
|
||||||
|
- **Provider-specific semantics retained and why**: provider-owned operation families such as Microsoft-specific `entra.*` codes remain valid current-release canonical codes where the operation itself is still provider-owned; this slice stabilizes shared contract ownership rather than renaming every provider-specific family
|
||||||
|
- **Why this does not deepen provider coupling accidentally**: the contract becomes stricter about one platform-owned identity path and removes peer truths; it does not generalize Microsoft semantics into platform core and does not require provider-specific codes to leak into generic vocabulary beyond their already-bounded domain ownership
|
||||||
|
- **Follow-up path**: follow-up-spec for broader governed-subject vocabulary enforcement and any later provider/domain naming cleanup that remains after this contract lands
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
||||||
|
|
||||||
|
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| Workspace-admin Operations list and drill-ins | yes | Native Filament resource plus shared monitoring shell | shared operations family | table, detail, filter, read-model summary | no | Canonical filter options and labels must collapse legacy aliases into one operation identity |
|
||||||
|
| System Ops pages (`Runs`, `Failures`, `Stuck`, `ViewRun`) | yes | Native Filament pages plus shared ops widgets | shared operations family | table, detail, widget summary | no | The same canonical operation identity must drive triage labels and filters across system views |
|
||||||
|
| Managed tenant onboarding bootstrap step | yes | Native Filament wizard plus shared start UX | shared onboarding and operation-launch family | wizard step, persisted workflow state | no | Bootstrap selections persist and resume as canonical codes only |
|
||||||
|
| Shared operation references and dashboard widgets | yes | Native widgets plus shared reference presenters | shared operations family | related link, widget card, summary text | no | Secondary context surfaces inherit the same canonical operation identity without local alias mapping |
|
||||||
|
|
||||||
|
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Workspace-admin Operations list and drill-ins | Primary Decision Surface | Decide what ran, whether it needs follow-up, and which run to inspect | operation label, status, outcome, initiator, started time, tenant scope | run context, artifact links, diagnostic detail, related navigation | Primary because this is the main workspace-admin run register where operators triage execution truth | Follows monitoring and troubleshooting workflow rather than storage internals | Removes duplicate alias choices and prevents the same operation family from appearing as different identities |
|
||||||
|
| System Ops pages | Primary Decision Surface | Decide which failed or stuck run needs platform/support attention | operation label, failure/stuck class, scope, recency, current status | low-level diagnostics and raw context on dedicated drill-ins | Primary because these are the platform triage surfaces for operational exceptions | Follows platform-ops triage workflow directly | Avoids re-learning alias variants across `Runs`, `Failures`, and `Stuck` views |
|
||||||
|
| Managed tenant onboarding bootstrap step | Primary Decision Surface | Decide which bootstrap operations to start for the tenant | selected bootstrap actions, operability state, permission availability | started run detail and post-start diagnostics | Primary because the operator is choosing and launching bootstrap work here | Follows onboarding progression rather than monitoring structure | Prevents saved selections and resumed drafts from drifting back to legacy raw identifiers |
|
||||||
|
| Shared operation references and dashboard widgets | Secondary Context Surface | Decide whether to open a referenced operation from another workflow surface | operation label, health summary, recency | full run detail after drill-in | Secondary because these surfaces support a larger owning workflow and route into operations detail | Keeps navigation tied to the owning surface while reusing canonical operation identity | Reduces mental translation between widgets, references, and the operations register |
|
||||||
|
|
||||||
|
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Workspace-admin Operations list and drill-ins | List / Table / Bulk | Read-only Registry / Monitoring | Open one operation and inspect its result | Existing admin operation detail drill-in from the operations register | required | Existing safe secondary actions stay in `More` or the detail header only | Existing destructive-like resumable actions stay where already approved on detail surfaces | `/admin/operations` | existing admin operation detail drill-in | workspace, tenant filter, problem class, recency | Operations / Operation | operation label, status, outcome, tenant, and started time | none |
|
||||||
|
| System Ops pages | List / Table / Bulk | Monitoring / Triage Report | Open a failed or stuck operation for deeper diagnosis | Explicit system run drill-in | allowed | Existing secondary navigation stays in header or explicit row context only | none added | `/system/ops/runs`, `/system/ops/failures`, `/system/ops/stuck` | `/system/ops/runs/{run}` | platform scope, tenant/workspace context, problem class | Operations / Operation | failure/stuck state, operation label, scope, and recency | none |
|
||||||
|
| Managed tenant onboarding bootstrap step | Workflow / Wizard / Launch | Wizard Step / Launch Surface | Start the selected bootstrap operations | The onboarding step itself before launch, then the existing run link after start | forbidden | Secondary guidance lives in step text and follow-up summaries, not competing actions | none added | `/admin/onboarding` | existing operation detail drill-in after launch | workspace, tenant, verification readiness, bootstrap selection | Operations / Operation | which bootstrap operations will start and whether they are allowed | none |
|
||||||
|
| Shared operation references and dashboard widgets | Embedded Related Navigation | Related Link / Summary Card | Open the referenced operation | Explicit safe link only | forbidden | Footer or contextual link only | none | owning surface route | existing admin or system operation detail route | owning-surface context plus tenant/workspace scope | Operations / Operation | operation label, current health summary, recency | none |
|
||||||
|
|
||||||
|
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Workspace-admin Operations list and drill-ins | Workspace owner, manager, operator | Decide whether a run needs follow-up and inspect the correct run record | List and detail | What ran, and what needs attention? | operation label, status, outcome, initiator, tenant, started time | raw context, artifact detail, related navigation, low-level explanation | lifecycle, outcome, readiness/attention | inspection only until an existing approved follow-up action is chosen | Open operation, existing safe detail actions | Existing detail-owned dangerous actions only where already approved |
|
||||||
|
| System Ops pages | Platform operator or support reviewer | Decide which failed or stuck platform-visible run needs triage | List and detail | Which operation needs platform attention right now? | operation label, failure/stuck class, scope, recency | extended diagnostics and raw execution context on drill-in | lifecycle, failure/stuck state, recency | inspection only | Open operation, existing page-level navigation | none added |
|
||||||
|
| Managed tenant onboarding bootstrap step | Workspace owner or tenant manager | Decide which bootstrap operations to start and resume safely | Wizard step | Which bootstrap operations should this tenant start now? | selected bootstrap actions, permission state, existing bootstrap summary | run detail after launch, deeper diagnostics through existing run links | readiness, queued/running/completed bootstrap state | starts existing tenant operations only | Start bootstrap, continue onboarding | none added |
|
||||||
|
| Shared operation references and dashboard widgets | Workspace operator, reviewer, or platform operator | Decide whether to drill into a referenced operation from the owning surface | Embedded summary | Do I need to open this operation from here? | operation label, summary state, recency | full run detail after navigation | summary state, recency | none | Open operation | none |
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
- **New source of truth?**: yes, in contract terms only; the existing dotted catalog becomes the single normative `operation_type` truth instead of one of two competing truths
|
||||||
|
- **New persisted entity/table/artifact?**: no
|
||||||
|
- **New abstraction?**: no
|
||||||
|
- **New enum/state/reason family?**: no
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: no
|
||||||
|
- **Current operator problem**: Operators and reviewers cannot reliably tell whether two differently named run identities refer to the same operation family, and maintainers can accidentally reintroduce raw aliases into onboarding, provider dispatch, or monitoring flows because the contract is still split.
|
||||||
|
- **Existing structure is insufficient because**: `OperationCatalog` already defines canonical dotted meanings, but `OperationRunType`, provider operation definitions, and onboarding bootstrap state still treat raw aliases as first-class write truths, which means every consumer must keep translating and can drift independently.
|
||||||
|
- **Narrowest correct implementation**: Reuse the existing catalog as the single contract, converge the current write owners and read models around it, and keep only a bounded read-side compatibility seam for historical rows or draft state encountered during rollout.
|
||||||
|
- **Ownership cost**: Existing fixtures, enum-backed assumptions, onboarding tests, and provider operation guards must be updated to one canonical contract, and the temporary compatibility seam must be retired instead of silently becoming permanent.
|
||||||
|
- **Alternative intentionally rejected**: Keeping long-lived dual semantics behind `canonicalCode()` translation or adding a broader new resolver framework was rejected because both preserve drift rather than removing it. A wider vocabulary cleanup was rejected because it would broaden scope beyond `operation_type` truth.
|
||||||
|
- **Release truth**: current-release truth and anti-drift hardening, not future speculative generalization
|
||||||
|
|
||||||
|
### Compatibility posture
|
||||||
|
|
||||||
|
This feature assumes a pre-production environment.
|
||||||
|
|
||||||
|
Canonical replacement is preferred over preservation.
|
||||||
|
|
||||||
|
The only allowed compatibility seam is a narrowly bounded read-side translator for historical `operation_runs.type` rows and persisted onboarding state encountered during rollout. No new dual-write, no new fallback writer, and no new long-lived legacy alias fixture policy are allowed.
|
||||||
|
|
||||||
|
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||||
|
|
||||||
|
- **Test purpose / classification**: Unit, Feature, Heavy-Governance
|
||||||
|
- **Validation lane(s)**: fast-feedback, confidence, heavy-governance
|
||||||
|
- **Why this classification and these lanes are sufficient**: the contract change is proven by narrow resolution and guard behavior plus representative operator-facing surfaces. Unit coverage proves canonical-code ownership, alias resolution, and registry normalization. Focused feature coverage proves monitoring filters, onboarding resume/start behavior, DB-only operations rendering, tenant-safe detail navigation, and operation-facing labels or read models. Existing heavy-governance families prove platform vocabulary anti-drift plus workspace and tenant non-leakage on touched operations surfaces.
|
||||||
|
- **New or expanded test families**: focused operation-type contract guards, monitoring/filter convergence coverage, onboarding bootstrap canonicalization coverage, provider operation registry canonical-code coverage, and existing heavy-governance non-leakage coverage on touched operations surfaces
|
||||||
|
- **Fixture / helper cost impact**: low to moderate; implementation should reuse existing `OperationRun`, onboarding session, workspace, tenant, and provider fixtures while shrinking legacy alias fixtures over time rather than adding a new helper layer
|
||||||
|
- **Heavy-family visibility / justification**: existing heavy-governance families in `tests/Architecture` and `tests/Feature/OpsUx` are touched because the feature changes platform-core vocabulary and tenant-safe operation visibility; no new heavy family is introduced
|
||||||
|
- **Special surface test profile**: monitoring-state-page plus standard-native-filament onboarding coverage
|
||||||
|
- **Standard-native relief or required special coverage**: ordinary feature coverage remains sufficient for the primary operator surfaces; browser proof is not required. Existing heavy-governance families remain necessary for anti-drift and non-leakage proof.
|
||||||
|
- **Reviewer handoff**: reviewers must confirm that write-time callers stop emitting raw aliases, filter options collapse duplicate alias variants into one canonical choice, shared links and summary widgets preserve the same canonical operation identity, onboarding resume normalizes historical selections, operations tenant-scope and tenantless viewer behavior remain entitlement-safe, DB-only operations rendering stays DB-only, and compatibility remains read-side only and temporary
|
||||||
|
- **Budget / baseline / trend impact**: small increase in focused fast-feedback and confidence coverage plus reuse of existing heavy-governance families; no new heavy family or browser lane
|
||||||
|
- **Escalation needed**: none
|
||||||
|
- **Active feature PR close-out entry**: Guardrail
|
||||||
|
- **Planned validation commands**:
|
||||||
|
- `Current repo baseline before implementation: export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationTypeResolutionTest.php tests/Unit/Providers/ProviderOperationStartGateTest.php tests/Unit/Onboarding/OnboardingDraftResolverTest.php`
|
||||||
|
- `Post-implementation expanded unit proof after the planned canonical-contract tests land: export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationTypeResolutionTest.php tests/Unit/Support/OperationRunTypeCanonicalContractTest.php tests/Unit/Providers/ProviderOperationRegistryCanonicalTypeTest.php tests/Unit/Providers/ProviderOperationStartGateTest.php tests/Unit/Onboarding/OnboardingDraftResolverTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunListFiltersTest.php tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php tests/Feature/Monitoring/AuditCoverageOperationsTest.php tests/Feature/Monitoring/OperationsTenantScopeTest.php tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php tests/Feature/ManagedTenantOnboardingWizardTest.php tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Architecture/PlatformVocabularyBoundaryGuardTest.php tests/Feature/Guards/OperationRunLinkContractGuardTest.php tests/Feature/OpsUx/UnknownOperationTypeLabelTest.php tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php`
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - See One Operation Identity Everywhere (Priority: P1)
|
||||||
|
|
||||||
|
As a workspace or platform operator, I want one consistent operation identity behind monitoring labels, filters, and references so I can trust that the same operation family is not being split across multiple raw names.
|
||||||
|
|
||||||
|
**Why this priority**: This is the primary trust and workflow problem. If monitoring and related surfaces still treat `inventory_sync` and `inventory.sync` as different truths, the platform keeps teaching the wrong contract.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by loading covered operations surfaces with historical and current run rows and confirming that one canonical operation identity and one label source are used for the same operation family.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** historical runs contain a raw alias such as `inventory_sync` and newer runs contain `inventory.sync`, **When** the operator opens a covered operations surface, **Then** both resolve under one canonical operation identity and one filter choice rather than appearing as separate operation families.
|
||||||
|
2. **Given** a related-navigation link or dashboard widget references an operation run, **When** the operator views it, **Then** the operation label comes from the same canonical source used by the operations register.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Resume Onboarding Without Rewriting Legacy Drift (Priority: P1)
|
||||||
|
|
||||||
|
As a workspace admin using onboarding bootstrap actions, I want saved selections and resumed drafts to normalize to canonical operation codes so the wizard does not keep writing legacy raw values back into the platform.
|
||||||
|
|
||||||
|
**Why this priority**: Onboarding is an active write surface today. If it keeps persisting legacy aliases, the platform contract cannot converge even if monitoring labels do.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by opening the bootstrap step with historical draft state, resuming the wizard, saving again, and starting bootstrap actions to verify canonical codes are used for persistence and start payloads.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an onboarding draft contains legacy bootstrap selections such as `inventory_sync`, **When** the wizard resumes and the operator saves or starts bootstrap, **Then** the persisted selection is canonicalized to the dotted code and no new legacy write is produced.
|
||||||
|
2. **Given** the operator starts bootstrap actions from the onboarding step, **When** the run start is delegated, **Then** the shared OperationRun UX path receives canonical operation codes only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Extend Operation Families Without Creating Peer Truths (Priority: P2)
|
||||||
|
|
||||||
|
As a maintainer or reviewer, I want one platform-owned operation-type contract so future provider or monitoring work cannot silently reintroduce raw alias writers or alternate registry truth.
|
||||||
|
|
||||||
|
**Why this priority**: The feature is justified not only by current operator clarity but by preventing the same semantic hotspot from reopening as more governance and provider work lands.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by exercising focused guard coverage that fails when a new in-scope writer or registry definition emits a raw alias as first-class operation identity.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a new in-scope operation definition or launch path is added using a legacy raw alias, **When** focused guard coverage runs, **Then** the change fails because the platform contract is no longer allowed to accept that alias as write-time truth.
|
||||||
|
2. **Given** historical rows still contain legacy aliases, **When** read models resolve them, **Then** the compatibility seam remains read-side only and does not turn those aliases back into approved write-time contract values.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- Historical `operation_runs.type` rows may still contain aliases such as `baseline_capture`, `baseline_compare`, `inventory_sync`, `entra_group_sync`, or `backup_schedule_run`, and covered read surfaces must still resolve them correctly during rollout.
|
||||||
|
- Persisted onboarding draft state may already contain alias arrays such as `inventory_sync`, and resume behavior must normalize them without silently writing the old value back.
|
||||||
|
- Some existing canonical dotted codes already contain underscore segments, such as `backup_set.update` or `tenant.review_pack.generate`; this slice must not accidentally widen into a cosmetic segment-rename campaign.
|
||||||
|
- Unknown or unsupported operation types must remain explicitly unknown rather than falling through to a misleading nearby label.
|
||||||
|
- A single canonical filter option may need to match multiple historical raw values, and covered surfaces must not expose duplicate filter choices or contradictory counts.
|
||||||
|
- Authorization boundaries must remain intact even when canonicalization collapses multiple stored raw values into one visible operation identity.
|
||||||
|
|
||||||
|
## Acceptance Criteria Summary
|
||||||
|
|
||||||
|
- Covered write surfaces emit only canonical dotted `operation_type` codes after implementation.
|
||||||
|
- Covered read surfaces collapse legacy aliases into one canonical operation identity without duplicate filter choices.
|
||||||
|
- Historical rows and draft state remain readable during rollout through a bounded read-side seam only.
|
||||||
|
- Guard coverage blocks the reintroduction of raw alias writers or alternate registry truth on in-scope paths.
|
||||||
|
|
||||||
|
## Implementation Close-Out
|
||||||
|
|
||||||
|
- Covered current-release writers now emit canonical dotted values directly through `OperationRunType`, provider registry/start paths, onboarding bootstrap starts, lifecycle config, and representative inventory, backup, evidence, review, baseline, and directory writers.
|
||||||
|
- The remaining compatibility seam is documented as read-side only for historical `operation_runs.type` rows, queued-run reauthorization, filter matching, and persisted onboarding draft normalization; legacy aliases remain `write_allowed: false`.
|
||||||
|
- Validation completed through focused US1, US2, US3, and affected writer/queue regression lanes before final quickstart validation.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature changes the contract around existing long-running operations, not the existence of those operations. Any in-scope start, read-model, or audit change must preserve current `OperationRun` observability, tenant/workspace isolation, auditability, and service-owned lifecycle behavior while canonicalizing `operation_type` identity.
|
||||||
|
|
||||||
|
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature promotes an existing platform-core operation catalog to the single normative contract and removes peer truths. It does not add new persistence, new states, new registries, or a second abstraction layer.
|
||||||
|
|
||||||
|
**Constitution alignment (XCUT-001):** This is cross-cutting across monitoring labels, filter options, launch surfaces, onboarding bootstrap state, audit metadata, reference summaries, and provider operation definitions. It must reuse the existing shared `OperationCatalog` path rather than create a parallel translator or presenter family.
|
||||||
|
|
||||||
|
**Constitution alignment (PROV-001):** `operation_type` remains platform-core. Provider operation bindings continue to describe provider-owned behavior, but they must consume canonical platform-owned operation codes rather than introduce alternate raw identifiers as shared truth.
|
||||||
|
|
||||||
|
**Constitution alignment (TEST-GOV-001):** Proof stays in focused unit, feature, and existing heavy-governance lanes. No browser lane and no new heavy-governance family are justified. New coverage must make alias drift, non-leakage, and DB-only render safety explicit without expanding default fixture cost or creating a new test-helper framework.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-UX):** This feature reuses existing `OperationRun` behavior. The default 3-surface feedback contract remains intact, `OperationRun.status` and `OperationRun.outcome` remain service-owned through `OperationRunService`, `summary_counts` semantics remain unchanged, and scheduled/system-run terminal notification behavior remains unchanged.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-UX-START-001):** The feature includes the `OperationRun UX Impact` section and keeps start-surface behavior delegated to the existing shared path. Local surfaces are limited to initiation inputs and canonical operation selection; they do not own queued toast, link, browser-event, or notification behavior.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** Authorization planes remain unchanged. Non-members remain 404, members lacking capability remain 403, and covered operations/global references remain tenant-safe. No destructive confirmation rule changes are introduced.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable.
|
||||||
|
|
||||||
|
**Constitution alignment (BADGE-001):** Existing run status and outcome badges remain centralized and unchanged. This slice does not introduce a new badge family or local status mapping.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** UI-FIL-001 is satisfied. Covered admin and system surfaces stay on native Filament resources, pages, widgets, forms, and sections plus existing shared presenters. No local replacement markup or local status language is introduced.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001):** The target object is the operation and its platform `operation_type` identity. Operator-facing labels continue to use existing operation display labels derived from the canonical catalog. Raw storage aliases remain implementation details and must not become primary operator-facing copy.
|
||||||
|
|
||||||
|
**Constitution alignment (DECIDE-001):** Operations registers and onboarding bootstrap remain primary decision surfaces. Shared references and widgets remain secondary context surfaces that route to the same canonical run truth.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** The affected surface classes, inspect models, routes, scope cues, default-visible truth, and action placements are captured in the surface tables above. No exemption is required.
|
||||||
|
|
||||||
|
**Constitution alignment (ACTSURF-001 - action hierarchy):** This slice does not add a new action family. Existing navigation, mutation, and dangerous-action placement remain unchanged while canonical operation identity is normalized underneath them.
|
||||||
|
|
||||||
|
**Constitution alignment (OPSURF-001):** Default-visible content remains operator-first on covered surfaces. Operators continue to see labels, status, outcome, and scope rather than raw alias strings. Diagnostic identity details remain secondary.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct mapping from the canonical operation catalog to UI labels and filters is sufficient. The slice exists to remove redundant truth, not add another interpretation layer.
|
||||||
|
|
||||||
|
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract is satisfied. Each affected surface keeps one primary inspect/open model, redundant `View` actions remain absent, empty action groups remain absent, and destructive action placement remains unchanged. The UI Action Matrix below records the affected surfaces.
|
||||||
|
|
||||||
|
**Constitution alignment (UX-001 — Layout & Information Architecture):** Covered Filament screens keep their existing native layouts, sections, tables, and empty-state behavior. No UX-001 exemption is required because this slice changes contract truth and shared labels/filters rather than introducing new layouts.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-239-001**: Existing catalog-defined dotted `operation_type` codes MUST become the single normative platform contract for in-scope operation identity.
|
||||||
|
- **FR-239-002**: All new or updated in-scope operation starts, provider operation definitions, audit metadata payloads, and onboarding bootstrap selections MUST emit or persist canonical dotted codes rather than raw legacy aliases.
|
||||||
|
- **FR-239-003**: Existing canonical dotted codes that already contain underscore segments, such as `backup_set.update` or `tenant.review_pack.generate`, MUST remain valid canonical codes in this slice; this feature MUST NOT widen into a cosmetic renaming campaign.
|
||||||
|
- **FR-239-004**: No in-scope write-time caller MAY require a second translation step such as `canonicalCode()` to discover the normative platform contract after implementation; the emitted or stored value itself must already be canonical.
|
||||||
|
- **FR-239-005**: `OperationCatalog` MUST remain the sole shared resolver for canonical operation code, operator label, alias retirement metadata, and filter-option convergence.
|
||||||
|
- **FR-239-006**: Historical raw values MAY be resolved only at read time for legacy `operation_runs.type` rows or persisted onboarding draft state encountered during rollout.
|
||||||
|
- **FR-239-007**: The compatibility seam for historical aliases MUST be bounded, documented, and removable; no dual-write, fallback writer, or long-lived alias-preservation framework is allowed.
|
||||||
|
- **FR-239-008**: Covered operations collection and detail surfaces MUST collapse alias variants into one canonical operation identity and one operator label source.
|
||||||
|
- **FR-239-009**: Covered monitoring, reporting, reference, and audit surfaces that describe an operation type MUST derive from the canonical code or its catalog label rather than rendering raw legacy aliases as first-class truth.
|
||||||
|
- **FR-239-010**: The onboarding bootstrap step MUST normalize legacy saved selections on resume and MUST persist only canonical dotted codes after any in-scope edit, save, or start action.
|
||||||
|
- **FR-239-011**: Provider operation registries, bindings, and start-gate payloads MUST define `operation_type` using canonical dotted codes rather than raw aliases.
|
||||||
|
- **FR-239-012**: Unknown or unsupported operation types MUST remain explicitly unknown and MUST NOT silently inherit a nearby canonical label or alias match.
|
||||||
|
- **FR-239-013**: Existing `OperationRun` lifecycle, dedupe, notification, and authorization behavior MUST remain unchanged except for canonical operation-type identity.
|
||||||
|
- **FR-239-014**: Existing 404 versus 403 semantics across workspace-admin, tenant-context, and system operations surfaces MUST remain unchanged.
|
||||||
|
- **FR-239-015**: The first implementation slice MUST cover `OperationRunType`, `OperationCatalog`, provider operation definitions and start-gate consumers, onboarding bootstrap state, operations filter/read-model helpers, and focused guard coverage.
|
||||||
|
- **FR-239-016**: The feature MUST NOT add new tables, new operation families, new provider abstraction frameworks, or a broader monitoring information-architecture redesign.
|
||||||
|
- **FR-239-017**: Regression coverage MUST fail if a new underscore-style or otherwise non-canonical write-time identifier becomes accepted as platform-core truth on any covered writer or registry path.
|
||||||
|
- **FR-239-018**: `operation_type` MUST remain classified as a platform-core canonical noun, while `operation_runs.type` remains only a temporary compatibility storage seam during rollout.
|
||||||
|
- **FR-239-019**: Later vocabulary cleanup outside `operation_type` truth, including broader governed-subject key work and unrelated domain naming, MUST remain outside this slice.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Renaming every existing canonical code segment to eliminate embedded underscores
|
||||||
|
- Reworking operation navigation, dashboard information architecture, or monitoring page layout
|
||||||
|
- Introducing a new registry, resolver, presenter, or taxonomy framework for operation identity
|
||||||
|
- Adding new operation families or expanding provider-neutral runtime scope
|
||||||
|
- Performing application-code implementation in this spec artifact
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- LEAN-001 applies: the product is still pre-production, so canonical replacement is preferred over preserving raw alias writers.
|
||||||
|
- The existing `OperationCatalog` dotted definitions are the closest current platform truth and are sufficient as the single normative contract for this slice.
|
||||||
|
- Historical `operation_runs.type` rows and persisted onboarding drafts may still contain legacy raw aliases when implementation starts.
|
||||||
|
- Operator-facing copy continues to use current display labels derived from the canonical catalog rather than exposing dotted codes by default, except on surfaces that intentionally show identifiers.
|
||||||
|
- Existing workspace, tenant, and platform authorization rules already cover the affected surfaces and do not need redesign for this slice.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `specs/171-operations-naming-consolidation/spec.md` for operator-visible naming alignment already completed separately from internal operation-type truth
|
||||||
|
- `specs/237-provider-boundary-hardening/spec.md` for shared provider/platform seam discipline
|
||||||
|
- `specs/238-provider-identity-target-scope/spec.md` as the adjacent anti-drift lane that keeps provider-neutral platform truth bounded correctly
|
||||||
|
- Existing platform vocabulary classification that marks `operation_type` as a platform-core canonical noun and `operation_runs.type` as a rollout compatibility seam
|
||||||
|
- Existing operations, onboarding, provider-dispatch, reference, and audit surfaces that already consume operation identity today
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- A bounded read-side compatibility seam could accidentally become permanent if in-scope write paths are not fully converged in the same implementation slice.
|
||||||
|
- The slice can sprawl into broader vocabulary cleanup unless planning and review keep the scope locked to `operation_type` truth only.
|
||||||
|
- Partial convergence across monitoring, onboarding, and provider dispatch would still leave contradictory platform truth even if one surface appears fixed.
|
||||||
|
- Existing fixtures and historical assumptions may be numerous enough that reviewers are tempted to preserve alias writers instead of replacing them.
|
||||||
|
|
||||||
|
## Resolved Planning Notes
|
||||||
|
|
||||||
|
- Canonical dotted codes that legitimately retain underscore segments and must not trigger broader renaming include `backup_set.update`, `directory.role_definitions.sync`, `tenant.review_pack.generate`, `tenant.evidence.snapshot.generate`, `entra.admin_roles.scan`, and `rbac.health_check`.
|
||||||
|
- The first implementation pass must explicitly cover non-UI exports and stored summaries that still surface raw `operation_runs.type`, including `OperationRunService`, `OperationRunTriageService`, `FindingsLifecycleBackfillRunbookService`, shared related-run links, and summary-widget read models.
|
||||||
|
|
||||||
|
## Follow-up Candidates
|
||||||
|
|
||||||
|
- Platform Vocabulary Boundary Enforcement for Governed Subject Keys
|
||||||
|
- Retirement of the bounded read-side compatibility seam once historical rows and fixtures have been replaced
|
||||||
|
- Any later provider/domain vocabulary cleanup that remains necessary after the canonical `operation_type` contract is fully converged
|
||||||
|
- Customer Review Workspace v1 and broader review-surface work after this platform-core semantic drift is removed
|
||||||
|
|
||||||
|
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||||
|
|
||||||
|
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Workspace-admin Operations surfaces | existing admin operations resource and tenantless detail viewer | no new header actions | existing clickable-row or safe detail drill-in | existing inspect action(s) only | existing grouped bulk behavior remains unchanged | existing empty-state behavior unchanged | existing back, refresh, related links, and approved resumable actions remain | n/a | no new audit behavior | Only the canonical operation-type contract behind labels and filters changes |
|
||||||
|
| System Ops pages | existing system pages for runs, failures, stuck, and run detail | no new header actions | existing view-run drill-in | existing inspect action only | none added | existing page behavior unchanged | existing page-level navigation and refresh remain | n/a | no new audit behavior | Canonical operation-type filters and labels must match workspace-admin operations truth |
|
||||||
|
| Managed tenant onboarding bootstrap step | existing onboarding wizard bootstrap step | none added | the wizard step itself before launch; existing operation link after launch | `Start bootstrap` remains existing launch action | none | existing onboarding CTA behavior remains | n/a | existing continue/cancel behavior remains | existing onboarding audit behavior remains | Saved selections and resumed drafts normalize to canonical codes only |
|
||||||
|
| Shared operation references and widgets | existing dashboard widgets and reference presenters | no new header actions | explicit safe link only | existing safe inspect link only | none | owning surface behavior unchanged | n/a | n/a | no new audit behavior | Secondary surfaces must not add local alias mapping or duplicate identity rules |
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Canonical Operation Type**: The single platform-owned dotted identifier that names an operation family consistently across starts, persistence, read models, filters, audit metadata, and operator-facing labels.
|
||||||
|
- **Historical Operation Type Alias**: A legacy raw value such as `inventory_sync` or `baseline_capture` that may still appear in older persisted rows or workflow state and is allowed only through the temporary read-side compatibility seam.
|
||||||
|
- **Operation Run**: The existing persisted operational record whose lifecycle, auditability, and visibility stay unchanged while its canonical operation identity is tightened.
|
||||||
|
- **Onboarding Bootstrap Selection**: The existing workflow-state list of optional bootstrap operations chosen during onboarding and normalized to canonical operation codes before persistence or launch.
|
||||||
|
- **Provider Operation Definition**: The existing provider-backed operation registry or binding entry that must consume canonical `operation_type` identity without becoming a second truth owner.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-239-001**: In all covered write paths, 100% of newly emitted or persisted in-scope operation identifiers use canonical dotted `operation_type` codes rather than legacy raw aliases.
|
||||||
|
- **SC-239-002**: On all covered monitoring and operations surfaces, 100% of alias variants for the same operation family collapse into one canonical operation identity and one filter choice.
|
||||||
|
- **SC-239-003**: Historical rows and resumed onboarding drafts using legacy aliases remain readable throughout rollout without producing any new legacy write on a covered save or start path.
|
||||||
|
- **SC-239-004**: Focused regression coverage fails whenever a covered writer, registry entry, or launch surface attempts to reintroduce a raw alias as first-class platform truth.
|
||||||
|
- **SC-239-005**: The feature lands without adding any new table, new operation family, new abstraction framework, or long-lived dual-write compatibility layer.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
Spec 239 is complete when the platform has one normative dotted `operation_type` contract for covered operations, historical alias rows remain readable only through a bounded read-side seam during rollout, onboarding and provider-dispatch surfaces stop writing legacy raw values, covered operations surfaces expose one canonical identity per operation family, and focused automated coverage blocks the semantic drift from returning.
|
||||||
242
specs/239-canonical-operation-type-source-of-truth/tasks.md
Normal file
242
specs/239-canonical-operation-type-source-of-truth/tasks.md
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
---
|
||||||
|
description: "Task list for Canonical Operation Type Source of Truth"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tasks: Canonical Operation Type Source of Truth
|
||||||
|
|
||||||
|
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/239-canonical-operation-type-source-of-truth/`
|
||||||
|
**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/239-canonical-operation-type-source-of-truth/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/239-canonical-operation-type-source-of-truth/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/239-canonical-operation-type-source-of-truth/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/239-canonical-operation-type-source-of-truth/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/239-canonical-operation-type-source-of-truth/contracts/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/239-canonical-operation-type-source-of-truth/quickstart.md`
|
||||||
|
|
||||||
|
**Tests**: REQUIRED (Pest) for runtime behavior changes; keep proof in the narrow `Unit`, `Feature`, and existing `Heavy-Governance` lanes named in the plan
|
||||||
|
**Operations**: No new `OperationRun` family, panel, or Monitoring IA is introduced; preserve the shared OperationRun Start UX contract while canonicalizing `operation_type` across touched write and read surfaces
|
||||||
|
**RBAC**: Preserve existing workspace, tenant, and platform authorization semantics on operations and onboarding surfaces, including unchanged `404` versus `403` behavior
|
||||||
|
**Provider Boundary**: `operation_type` remains platform-core; provider operation bindings stay provider-owned and may not reintroduce raw aliases as shared truth
|
||||||
|
|
||||||
|
**Organization**: Tasks are grouped by user story so canonical contract owners, monitoring parity, onboarding bootstrap state, provider-start writers, audit-adjacent summaries, and anti-drift guardrails can be implemented and validated incrementally without widening into broader naming cleanup or a generic compatibility framework.
|
||||||
|
|
||||||
|
## Test Governance Checklist
|
||||||
|
|
||||||
|
- [X] Lane assignment stays `Unit` plus `Feature` plus existing `Heavy-Governance` and remains the narrowest sufficient proof for the changed behavior.
|
||||||
|
- [X] New or changed tests stay in existing support, provider, onboarding, monitoring, and guard families; existing `tests/Architecture` and `tests/Feature/OpsUx` heavy-governance families may be reused, but no new heavy-governance family or browser lane is added.
|
||||||
|
- [X] Shared helpers, factories, seeds, onboarding draft defaults, and operation fixtures stay cheap by default; no new alias-preserving helper layer is introduced.
|
||||||
|
- [X] Planned validation commands cover canonical resolution, operations filter parity, tenant-safe operations visibility, DB-only monitoring rendering, onboarding bootstrap lifecycle, provider-start truth, and guardrails without widening scope.
|
||||||
|
- [X] The declared surface test profile remains `monitoring-state-page` plus `standard-native-filament`; no exception-coded surface or browser proof is required.
|
||||||
|
- [X] Any remaining compatibility seam resolves as `document-in-feature` or `follow-up-spec`, not as a silent generic compatibility framework.
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Context)
|
||||||
|
|
||||||
|
**Purpose**: Confirm the exact contract hotspots, owning files, and existing proof lanes before code changes begin.
|
||||||
|
|
||||||
|
- [X] T001 Review the current canonical contract owners in `apps/platform/app/Support/OperationRunType.php`, `apps/platform/app/Support/OperationCatalog.php`, and `apps/platform/config/tenantpilot.php`
|
||||||
|
- [X] T002 [P] Review representative write owners and onboarding bootstrap state in `apps/platform/app/Services/Providers/ProviderOperationRegistry.php`, `apps/platform/app/Services/Providers/ProviderOperationStartGate.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, and `apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php`
|
||||||
|
- [X] T003 [P] Review monitoring, filter, reference, audit, tenant-scope, and DB-only proof hotspots in `apps/platform/app/Filament/Resources/OperationRunResource.php`, `apps/platform/app/Support/Filament/FilterOptionCatalog.php`, `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`, `apps/platform/app/Support/References/Resolvers/OperationRunReferenceResolver.php`, `apps/platform/app/Services/Audit/AuditEventBuilder.php`, `apps/platform/tests/Feature/Filament/OperationRunListFiltersTest.php`, `apps/platform/tests/Feature/Monitoring/OperationsTenantScopeTest.php`, `apps/platform/tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php`, `apps/platform/tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php`, and `apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Collapse the shared canonical contract onto one platform-owned truth before any user story work begins.
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||||
|
|
||||||
|
- [X] T004 [P] Add or extend canonical resolution and unknown-label coverage in `apps/platform/tests/Unit/Support/OperationTypeResolutionTest.php` and `apps/platform/tests/Feature/OpsUx/UnknownOperationTypeLabelTest.php`
|
||||||
|
- [X] T005 [P] Add enum and vocabulary anti-drift coverage in `apps/platform/tests/Unit/Support/OperationRunTypeCanonicalContractTest.php` and `apps/platform/tests/Architecture/PlatformVocabularyBoundaryGuardTest.php`
|
||||||
|
- [X] T006 Update `apps/platform/app/Support/OperationRunType.php` and `apps/platform/app/Support/OperationCatalog.php` so canonical dotted `operation_type` values are the only normative write-time contract and aliases remain read-side only
|
||||||
|
- [X] T007 Update `apps/platform/config/tenantpilot.php` and `apps/platform/app/Support/Operations/OperationLifecyclePolicy.php` so covered lifecycle keys and policy lookups use canonical dotted `operation_type` values directly
|
||||||
|
|
||||||
|
**Checkpoint**: Shared canonical contract owners and guard foundations are ready; user story work can now proceed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - See One Operation Identity Everywhere (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Make operations lists, filters, references, and audit-adjacent monitoring summaries resolve one canonical operation identity instead of teaching alias variants as peer truths.
|
||||||
|
|
||||||
|
**Independent Test**: Load covered operations surfaces with historical and current rows and verify one canonical filter choice, one operator label source, and one canonical summary identity for the same operation family.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [X] T008 [P] [US1] Extend canonical filter convergence coverage in `apps/platform/tests/Feature/Filament/OperationRunListFiltersTest.php`
|
||||||
|
- [X] T009 [P] [US1] Extend monitoring, widget-summary, related-link, unknown-label, tenant-scope, tenantless-viewer, DB-only, and audit-adjacent canonicalization coverage in `apps/platform/tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php`, `apps/platform/tests/Feature/Guards/OperationRunLinkContractGuardTest.php`, `apps/platform/tests/Feature/Monitoring/AuditCoverageOperationsTest.php`, `apps/platform/tests/Feature/Monitoring/OperationsTenantScopeTest.php`, `apps/platform/tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php`, `apps/platform/tests/Feature/OpsUx/UnknownOperationTypeLabelTest.php`, `apps/platform/tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php`, and `apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [X] T010 [US1] Replace raw type comparisons and duplicate option logic in `apps/platform/app/Filament/Resources/OperationRunResource.php` and `apps/platform/app/Support/Filament/FilterOptionCatalog.php`
|
||||||
|
- [X] T011 [P] [US1] Align shared labels and related-run links with canonical resolution in `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`, `apps/platform/app/Support/References/Resolvers/OperationRunReferenceResolver.php`, and `apps/platform/app/Support/OperationRunLinks.php`
|
||||||
|
- [X] T012 [P] [US1] Canonicalize audit-adjacent operation metadata in `apps/platform/app/Services/Audit/AuditEventBuilder.php` and `apps/platform/app/Services/OperationRunService.php`
|
||||||
|
- [X] T013 [P] [US1] Canonicalize monitoring and summary payloads in `apps/platform/app/Services/SystemConsole/OperationRunTriageService.php`, `apps/platform/app/Services/Evidence/Sources/OperationsSummarySource.php`, and `apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php`
|
||||||
|
- [X] T014 [US1] Run the relevant quickstart verification blocks for US1 from `specs/239-canonical-operation-type-source-of-truth/quickstart.md` (expanded narrow proving lane, representative operator-surface proof, and guardrail proof) against `apps/platform/tests/Feature/Filament/OperationRunListFiltersTest.php`, `apps/platform/tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php`, `apps/platform/tests/Feature/Guards/OperationRunLinkContractGuardTest.php`, `apps/platform/tests/Feature/Monitoring/AuditCoverageOperationsTest.php`, `apps/platform/tests/Feature/Monitoring/OperationsTenantScopeTest.php`, `apps/platform/tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php`, `apps/platform/tests/Feature/OpsUx/UnknownOperationTypeLabelTest.php`, `apps/platform/tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php`, `apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, and `apps/platform/tests/Unit/Support/OperationTypeResolutionTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 1 is independently deliverable as the core monitoring and read-model parity slice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Resume Onboarding Without Rewriting Legacy Drift (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Make onboarding bootstrap resume, save, and start flows normalize legacy selections to canonical dotted operation codes without rewriting the platform back to raw aliases.
|
||||||
|
|
||||||
|
**Independent Test**: Resume an onboarding draft with legacy bootstrap selections, save it again, and start bootstrap actions to verify canonical operation codes are persisted and handed to the shared start UX path.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [X] T015 [P] [US2] Extend onboarding draft canonicalization coverage in `apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php` and `apps/platform/tests/Unit/Onboarding/OnboardingDraftResolverTest.php`
|
||||||
|
- [X] T016 [P] [US2] Extend onboarding provider-start and authorization-safe bootstrap coverage in `apps/platform/tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php` and `apps/platform/tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [X] T017 [US2] Normalize `bootstrap_operation_types` and `started_operation_type` on load, save, resume, and start in `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` and `apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php`
|
||||||
|
- [X] T018 [US2] Keep resumed consent and callback flows writing canonical bootstrap state in `apps/platform/app/Http/Controllers/AdminConsentCallbackController.php` and `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
|
||||||
|
- [X] T019 [US2] Align onboarding audit metadata and shared start handoff with canonical operation codes in `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` and `apps/platform/app/Services/Audit/AuditEventBuilder.php`
|
||||||
|
- [X] T020 [US2] Run the relevant quickstart verification blocks for US2 from `specs/239-canonical-operation-type-source-of-truth/quickstart.md` (expanded narrow proving lane plus representative operator-surface proof) against `apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php`, `apps/platform/tests/Unit/Onboarding/OnboardingDraftResolverTest.php`, `apps/platform/tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php`, and `apps/platform/tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: User Stories 1 and 2 now expose the same canonical operation identity across monitoring and onboarding bootstrap state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Extend Operation Families Without Creating Peer Truths (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Harden provider-start and representative write paths so future work cannot silently reintroduce raw alias writers or alternate registry truth.
|
||||||
|
|
||||||
|
**Independent Test**: Run focused registry, start-gate, and architecture guard coverage and verify raw alias write-time values fail while historical read-side compatibility remains explicitly bounded.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [X] T021 [P] [US3] Add provider registry and start-gate canonical contract coverage in `apps/platform/tests/Unit/Providers/ProviderOperationRegistryCanonicalTypeTest.php` and `apps/platform/tests/Unit/Providers/ProviderOperationStartGateTest.php`
|
||||||
|
- [X] T022 [P] [US3] Extend raw-alias reintroduction guard coverage in `apps/platform/tests/Architecture/PlatformVocabularyBoundaryGuardTest.php` and `apps/platform/tests/Unit/Support/OperationRunTypeCanonicalContractTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [X] T023 [US3] Replace legacy registry `operation_type` values with canonical dotted codes and bounded read-side-only semantics in `apps/platform/app/Services/Providers/ProviderOperationRegistry.php` and `apps/platform/app/Services/Providers/ProviderOperationStartGate.php`
|
||||||
|
- [X] T024 [US3] Converge representative `OperationRun` writers on canonical dotted types in `apps/platform/app/Services/Inventory/InventorySyncService.php`, `apps/platform/app/Services/BackupScheduling/BackupScheduleDispatcher.php`, `apps/platform/app/Services/ReviewPackService.php`, `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php`, and `apps/platform/app/Services/TenantReviews/TenantReviewService.php`
|
||||||
|
- [X] T025 [US3] Document and enforce the remaining read-side compatibility boundary in `apps/platform/app/Support/OperationCatalog.php` and `specs/239-canonical-operation-type-source-of-truth/contracts/canonical-operation-type-source-of-truth.logical.openapi.yaml`
|
||||||
|
- [X] T026 [US3] Run the relevant quickstart verification blocks for US3 from `specs/239-canonical-operation-type-source-of-truth/quickstart.md` (expanded narrow proving lane plus guardrail proof) against `apps/platform/tests/Unit/Providers/ProviderOperationRegistryCanonicalTypeTest.php`, `apps/platform/tests/Unit/Providers/ProviderOperationStartGateTest.php`, `apps/platform/tests/Unit/Support/OperationRunTypeCanonicalContractTest.php`, and `apps/platform/tests/Architecture/PlatformVocabularyBoundaryGuardTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: All user stories are independently functional, and representative write paths are locked behind focused canonical-operation guardrails.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Refresh implementation notes, run final narrow validation, and close out the bounded compatibility seam explicitly.
|
||||||
|
|
||||||
|
- [X] T027 [P] Refresh implementation notes, logical contract wording, and validation commands in `specs/239-canonical-operation-type-source-of-truth/spec.md`, `specs/239-canonical-operation-type-source-of-truth/plan.md`, `specs/239-canonical-operation-type-source-of-truth/quickstart.md`, and `specs/239-canonical-operation-type-source-of-truth/contracts/canonical-operation-type-source-of-truth.logical.openapi.yaml`
|
||||||
|
- [X] T028 [P] Run formatting for touched files in `apps/platform/app/Support/`, `apps/platform/app/Services/`, `apps/platform/app/Filament/Pages/Workspaces/`, `apps/platform/app/Filament/Resources/`, `apps/platform/app/Http/Controllers/`, and `apps/platform/tests/`
|
||||||
|
- [X] T029 Run the full quickstart verification sequence from `specs/239-canonical-operation-type-source-of-truth/quickstart.md` against `apps/platform/tests/Unit/Support/OperationTypeResolutionTest.php`, `apps/platform/tests/Unit/Support/OperationRunTypeCanonicalContractTest.php`, `apps/platform/tests/Unit/Providers/ProviderOperationRegistryCanonicalTypeTest.php`, `apps/platform/tests/Unit/Providers/ProviderOperationStartGateTest.php`, `apps/platform/tests/Unit/Onboarding/OnboardingDraftResolverTest.php`, `apps/platform/tests/Feature/Filament/OperationRunListFiltersTest.php`, `apps/platform/tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php`, `apps/platform/tests/Feature/Guards/OperationRunLinkContractGuardTest.php`, `apps/platform/tests/Feature/Monitoring/AuditCoverageOperationsTest.php`, `apps/platform/tests/Feature/Monitoring/OperationsTenantScopeTest.php`, `apps/platform/tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php`, `apps/platform/tests/Feature/OpsUx/UnknownOperationTypeLabelTest.php`, `apps/platform/tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php`, `apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, `apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php`, `apps/platform/tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php`, `apps/platform/tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php`, and `apps/platform/tests/Architecture/PlatformVocabularyBoundaryGuardTest.php`
|
||||||
|
- [X] T030 Record the guardrail close-out, `document-in-feature` disposition for the bounded read-side seam, and any deferred `follow-up-spec` boundary in `specs/239-canonical-operation-type-source-of-truth/plan.md` and `specs/239-canonical-operation-type-source-of-truth/quickstart.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: No dependencies; can start immediately.
|
||||||
|
- **Foundational (Phase 2)**: Depends on Setup completion and blocks all user stories.
|
||||||
|
- **User Story 1 (Phase 3)**: Depends on Foundational completion.
|
||||||
|
- **User Story 2 (Phase 4)**: Depends on Foundational completion and should coordinate with User Story 1 where `apps/platform/app/Services/Audit/AuditEventBuilder.php` is shared.
|
||||||
|
- **User Story 3 (Phase 5)**: Depends on Foundational completion and should follow User Story 2 because provider-start hardening is the narrowest proof once onboarding handoff already emits canonical codes.
|
||||||
|
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **User Story 1 (P1)**: This is the demonstration MVP for operator-visible monitoring parity.
|
||||||
|
- **User Story 2 (P1)**: Conceptually independent after Phase 2, but it shares `apps/platform/app/Services/Audit/AuditEventBuilder.php` with User Story 1 and should reuse the same canonical metadata truth.
|
||||||
|
- **User Story 3 (P2)**: Depends on the foundational contract and should harden provider-start and representative writers only after User Story 2 proves onboarding now hands off canonical codes.
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Tests should be written and fail before the corresponding implementation tasks.
|
||||||
|
- Phase 2 must settle the canonical dotted contract before any read-model, onboarding, or provider-start adoption task lands.
|
||||||
|
- Serialize edits in `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` across T017, T018, and T019.
|
||||||
|
- Serialize edits in `apps/platform/app/Services/Audit/AuditEventBuilder.php` across T012 and T019.
|
||||||
|
- Finish each story’s validation task before moving to the next priority when working sequentially.
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- **Setup**: T002 and T003 can run in parallel.
|
||||||
|
- **Foundational**: T004 and T005 can run in parallel before T006; T007 follows T006.
|
||||||
|
- **US1 tests**: T008 and T009 can run in parallel.
|
||||||
|
- **US1 implementation**: T011, T012, and T013 can run in parallel after T010 settles the primary filter/read-model behavior.
|
||||||
|
- **US2 tests**: T015 and T016 can run in parallel.
|
||||||
|
- **US3 tests**: T021 and T022 can run in parallel.
|
||||||
|
- **Polish**: T027 and T028 can run in parallel before T029 and T030.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run US1 proof tasks in parallel:
|
||||||
|
T008 apps/platform/tests/Feature/Filament/OperationRunListFiltersTest.php
|
||||||
|
T009 apps/platform/tests/Feature/Monitoring/AuditCoverageOperationsTest.php and apps/platform/tests/Feature/OpsUx/UnknownOperationTypeLabelTest.php
|
||||||
|
|
||||||
|
# Then split non-overlapping implementation follow-up:
|
||||||
|
T011 apps/platform/app/Support/OpsUx/OperationUxPresenter.php, apps/platform/app/Support/References/Resolvers/OperationRunReferenceResolver.php, and apps/platform/app/Support/OperationRunLinks.php
|
||||||
|
T013 apps/platform/app/Services/SystemConsole/OperationRunTriageService.php, apps/platform/app/Services/Evidence/Sources/OperationsSummarySource.php, and apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run US2 proof tasks in parallel:
|
||||||
|
T015 apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php and apps/platform/tests/Unit/Onboarding/OnboardingDraftResolverTest.php
|
||||||
|
T016 apps/platform/tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php and apps/platform/tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 3
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run US3 guard tasks in parallel:
|
||||||
|
T021 apps/platform/tests/Unit/Providers/ProviderOperationRegistryCanonicalTypeTest.php and apps/platform/tests/Unit/Providers/ProviderOperationStartGateTest.php
|
||||||
|
T022 apps/platform/tests/Architecture/PlatformVocabularyBoundaryGuardTest.php and apps/platform/tests/Unit/Support/OperationRunTypeCanonicalContractTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Story 1 Demonstration)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Setup.
|
||||||
|
2. Complete Phase 2: Foundational.
|
||||||
|
3. Complete Phase 3: User Story 1.
|
||||||
|
4. Stop and validate with T014.
|
||||||
|
5. Review whether operations filters, labels, references, and audit-adjacent summaries now teach one canonical `operation_type` truth.
|
||||||
|
|
||||||
|
Even if User Story 1 is demoable on its own, User Stories 2 and 3 should land before merge because the active write surfaces and anti-drift guardrails are part of the accepted scope.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Setup and Foundational establish one canonical dotted contract and bounded read-side seam.
|
||||||
|
2. Add User Story 1 and validate monitoring/filter/read-model parity.
|
||||||
|
3. Add User Story 2 and validate onboarding bootstrap lifecycle and canonical start handoff.
|
||||||
|
4. Add User Story 3 and validate provider-start truth plus representative writer guardrails.
|
||||||
|
5. Finish with formatting, final validation, and explicit close-out of the remaining compatibility seam.
|
||||||
|
|
||||||
|
### Parallel Team Strategy
|
||||||
|
|
||||||
|
With multiple developers:
|
||||||
|
|
||||||
|
1. Complete Setup and Foundational together.
|
||||||
|
2. After Phase 2:
|
||||||
|
- Developer A: User Story 1 read-model parity in `apps/platform/app/Filament/Resources/OperationRunResource.php`, `apps/platform/app/Support/Filament/FilterOptionCatalog.php`, and the shared presenter/reference files.
|
||||||
|
- Developer B: User Story 2 onboarding bootstrap normalization in `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` and `apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php`.
|
||||||
|
- Developer C: User Story 3 provider-start and representative write-path hardening in `apps/platform/app/Services/Providers/ProviderOperationRegistry.php`, `apps/platform/app/Services/Providers/ProviderOperationStartGate.php`, and the representative writer services.
|
||||||
|
3. Serialize edits in `apps/platform/app/Services/Audit/AuditEventBuilder.php` and `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` because multiple story tasks touch those files.
|
||||||
|
|
||||||
|
### Suggested MVP Scope
|
||||||
|
|
||||||
|
The narrowest demonstration slice is Phase 1 through Phase 3. The narrowest merge-ready slice is Phase 1 through Phase 5, with Phase 6 reserved for close-out and final proof.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `[P]` marks tasks that can run in parallel once prerequisites are satisfied and files do not overlap.
|
||||||
|
- `[US1]`, `[US2]`, and `[US3]` map directly to the user stories in `spec.md`.
|
||||||
|
- Keep this feature strictly scoped to canonical `operation_type` truth. Do not widen into broader naming cleanup, governed-subject taxonomy work, monitoring IA redesign, or a generic compatibility framework.
|
||||||
|
- The only allowed compatibility seam is read-side resolution for historical `operation_runs.type` rows and persisted onboarding draft state already covered in the plan and logical contract.
|
||||||
Loading…
Reference in New Issue
Block a user