diff --git a/.npmignore b/.npmignore
index e669c3f..204381c 100644
--- a/.npmignore
+++ b/.npmignore
@@ -2,6 +2,7 @@ dist/
build/
public/build/
node_modules/
+vendor/
*.log
.env
.env.*
diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md
index 3df8c1e..dd44daa 100644
--- a/.specify/memory/constitution.md
+++ b/.specify/memory/constitution.md
@@ -1,16 +1,11 @@
@if($runs->isNotEmpty())
diff --git a/resources/views/livewire/monitoring/operations-detail.blade.php b/resources/views/livewire/monitoring/operations-detail.blade.php
new file mode 100644
index 0000000..aebedd5
--- /dev/null
+++ b/resources/views/livewire/monitoring/operations-detail.blade.php
@@ -0,0 +1,60 @@
+
+
+
+
Summary
+
+ - Type:
+ - {{ $run->type }}
+
+ - Status:
+ - {{ $run->status }}
+
+ - Outcome:
+ - {{ $run->outcome }}
+
+ - Initiator:
+ - {{ $run->initiator_name }}
+
+
+
+
+
Timing
+
+ - Created:
+ - {{ $run->created_at }}
+
+ - Started:
+ - {{ $run->started_at ?? '-' }}
+
+ - Completed:
+ - {{ $run->completed_at ?? '-' }}
+
+
+
+
+ @if(!empty($run->summary_counts))
+
+
Counts
+
{{ json_encode($run->summary_counts, JSON_PRETTY_PRINT) }}
+
+ @endif
+
+ @if(!empty($run->failure_summary))
+
+
Failures
+
+ @foreach($run->failure_summary as $failure)
+
+
{{ $failure['code'] ?? 'UNKNOWN' }}
+
{{ $failure['message'] ?? 'Unknown error' }}
+
+ @endforeach
+
+
+ @endif
+
+
+
Context
+
{{ json_encode($run->context, JSON_PRETTY_PRINT) }}
+
+
diff --git a/routes/console.php b/routes/console.php
index 49eb3f5..de19a60 100644
--- a/routes/console.php
+++ b/routes/console.php
@@ -1,5 +1,6 @@
everyMinute();
Schedule::command('tenantpilot:directory-groups:dispatch')->everyMinute();
+
+Schedule::job(new PruneOldOperationRunsJob)
+ ->daily()
+ ->name(PruneOldOperationRunsJob::class)
+ ->withoutOverlapping();
diff --git a/specs/046-inventory-sync-button/research.md b/specs/046-inventory-sync-button/research.md
index f9ca3eb..f95f247 100644
--- a/specs/046-inventory-sync-button/research.md
+++ b/specs/046-inventory-sync-button/research.md
@@ -54,7 +54,7 @@ ### Decision: Default selection = “full inventory”
### Decision: Attribute initiator on run record and audit trail
- **Chosen**: Store initiator identity on `InventorySyncRun` and also emit an audit record.
-- **Rationale**: Improves traceability and aligns with constitution principle “Automation must be Idempotent & Observable”.
+- **Rationale**: Improves traceability and aligns with constitution principle “Operations / Run Observability Standard”.
- **Alternatives considered**:
- Audit log only — rejected (you chose C).
diff --git a/specs/049-backup-restore-job-orchestration/research.md b/specs/049-backup-restore-job-orchestration/research.md
index f22e7c9..344a2f5 100644
--- a/specs/049-backup-restore-job-orchestration/research.md
+++ b/specs/049-backup-restore-job-orchestration/research.md
@@ -42,7 +42,7 @@ ### 3) Idempotency & de-duplication
- Behavior: if an identical run is `queued`/`running`, reuse it and return/link to it; allow a new run only after terminal.
**Rationale:**
-- Matches the constitution (“Automation must be Idempotent & Observable”) and aligns with existing patterns (inventory selection hash + schedule locks).
+- Matches the constitution (“Operations / Run Observability Standard”) and aligns with existing patterns (inventory selection hash + schedule locks).
**Alternatives considered:**
- **Cache-only locks** (`Cache::lock(...)`) without persisted keys.
@@ -75,4 +75,3 @@ ## Clarifications resolved
- **SC-003 includes “canceled”** while Phase 1 explicitly has “no cancel”.
- Resolution for Phase 1 planning: treat “canceled” as out-of-scope (Phase 2+) and map “aborted” (if present) into the `failed` bucket for SC accounting.
-
diff --git a/specs/053-unify-runs-monitoring/research.md b/specs/053-unify-runs-monitoring/research.md
index 1bc4cce..3afd57a 100644
--- a/specs/053-unify-runs-monitoring/research.md
+++ b/specs/053-unify-runs-monitoring/research.md
@@ -80,7 +80,7 @@ ### 6) Idempotency & de-duplication
- Race reduction: rely on the existing partial unique index for active runs and handle collisions by finding and reusing the existing run.
**Rationale:**
-- Aligns with the constitution (“Automation must be Idempotent & Observable”).
+- Aligns with the constitution (“Operations / Run Observability Standard”).
- Durable across restarts and observable in the database.
**Alternatives considered:**
diff --git a/specs/054-unify-runs-suitewide/checklists/requirements.md b/specs/054-unify-runs-suitewide/checklists/requirements.md
index 2886bac..70ccd84 100644
--- a/specs/054-unify-runs-suitewide/checklists/requirements.md
+++ b/specs/054-unify-runs-suitewide/checklists/requirements.md
@@ -6,6 +6,8 @@ ## Phase 1 Adoption Set
- [x] `directory_groups.sync` (Directory → Groups “Sync groups”) covered in spec
- [x] `drift.generate` (Drift “Generate drift now”) covered in spec
- [x] `backup_set.add_policies` (Backup Sets “Add selected”) covered in spec
+- [x] `backup_schedule.run_now` (Backup Schedules “Run now”) covered in implementation
+- [x] `backup_schedule.retry` (Backup Schedules “Retry”) covered in implementation
- [x] `restore.execute` (adapter mode) covered in spec
## Critical Clarifications (Pinned)
diff --git a/specs/054-unify-runs-suitewide/checklists/review.md b/specs/054-unify-runs-suitewide/checklists/review.md
new file mode 100644
index 0000000..0e20552
--- /dev/null
+++ b/specs/054-unify-runs-suitewide/checklists/review.md
@@ -0,0 +1,61 @@
+# Spec Review Checklist: Unified Operations Runs Suitewide (054)
+
+**Purpose**: Validate that `spec.md` is PR-reviewable and implementable by checking requirement quality (clarity, completeness, consistency, and testability), with emphasis on Audit-only vs OperationRun boundaries and tenant isolation/privacy/sanitization.
+**Created**: 2026-01-17
+**Feature**: `specs/054-unify-runs-suitewide/spec.md`
+
+**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements.
+
+## Requirement Completeness
+
+- [x] CHK001 Are Phase 1 adoption-set operations explicitly enumerated and scoped? [Completeness] Evidence: `spec.md` §Scope & Assumptions (“Phase 1 adoption set”)
+- [x] CHK002 Are required Phase 1 run types explicitly listed and stable (including restore adapter and backup schedules)? [Completeness] Evidence: `spec.md` §FR-003
+- [x] CHK003 Are mandatory run record fields specified (initiator, type, status/timestamps, outcome, counts, failures, identity, context)? [Completeness] Evidence: `spec.md` §FR-001
+- [x] CHK004 Are lifecycle states and outcome buckets defined with allowed values? [Completeness] Evidence: `spec.md` §FR-011–FR-012
+- [x] CHK005 Are idempotency identity rules specified for each Phase 1 run type (effective inputs included/excluded)? [Completeness] Evidence: `spec.md` §FR-010
+- [x] CHK006 Are role/permission requirements defined for viewing runs vs starting operations? [Completeness] Evidence: `spec.md` §FR-018 + §User Story 1 (Scenario 5) + §User Story 2 (Scenario 2)
+- [x] CHK007 Are notification requirements defined for queued and terminal outcomes (recipient, summary, “View run” link)? [Completeness] Evidence: `spec.md` §FR-015 + §User Story 2 (Scenario 3)
+
+## Audit-only vs OperationRun Boundaries
+
+- [x] CHK008 Is the eligibility criteria for Audit-only actions fully specified (DB-only, ≤2s, no remote calls, no background work)? [Clarity] Evidence: `spec.md` §FR-019
+- [x] CHK009 Is it unambiguous when an action may remain Audit-only versus must be an OperationRun (e.g., operational relevance vs queued/remote)? [Clarity] Evidence: `spec.md` §FR-019 + §Rule (Run vs Audit-only)
+- [x] CHK010 Are “security-relevant” / “operational behavior change” triggers for mandatory AuditLog entries defined beyond examples (so classification is reviewable)? [Clarity] Evidence: `spec.md` §FR-019 (“Trigger guidance”)
+- [x] CHK011 Are required AuditLog fields complete and unambiguous (tenant, actor, stable action ID, target, before/after or diff, timestamp)? [Completeness] Evidence: `spec.md` §FR-019
+- [x] CHK012 Does the spec define sanitization expectations for `before/after/diff` in AuditLog (what must be excluded) rather than assuming it? [Privacy] Evidence: `spec.md` §FR-019 (“Sanitization (AuditLog before/after/diff)”)
+- [x] CHK013 Does the adoption matrix cover the required feature areas and assign a stable `run_type` or audit action id for each? [Completeness] Evidence: `spec.md` §Run vs Audit-only Adoption Matrix (Phase 1)
+- [x] CHK014 Are the adoption matrix audit action identifiers consistent with the “stable action identifier” requirement for AuditLog entries? [Consistency] Evidence: `spec.md` §FR-019 + §Run vs Audit-only Adoption Matrix
+- [x] CHK015 Are FR-019 acceptance checks sufficient and non-contradictory (no OperationRun; exactly one AuditLog; tenant-scoped; cross-tenant forbidden)? [Acceptance Criteria] Evidence: `spec.md` §FR-019 (Acceptance checks)
+
+## Tenant Isolation & Privacy / Sanitization
+
+- [x] CHK016 Are tenant isolation requirements explicitly stated for run list access and run detail access? [Completeness] Evidence: `spec.md` §FR-016 + §User Story 1 (Scenarios 1, 6)
+- [x] CHK017 Is “cross-tenant access is denied without disclosing run details” sufficiently specified to be reviewable (what must not be exposed)? [Clarity] Evidence: `spec.md` §FR-016 + §User Story 1 (Scenario 6)
+- [x] CHK018 Are start-surface authorization requirements explicit enough to prevent unauthorized run creation (especially Readonly)? [Completeness] Evidence: `spec.md` §FR-007 + §FR-018 + §User Story 2 (Scenario 2)
+- [x] CHK019 Are persisted failure requirements explicit about what MUST NOT be stored (tokens/credentials/PII/raw payload dumps)? [Clarity] Evidence: `spec.md` §FR-013 + §SC-004
+- [x] CHK020 Are “stable reason codes” and “short sanitized messages” defined enough to be objectively reviewable (format expectations or examples)? [Clarity] Evidence: `spec.md` §FR-013 (Reason codes + Messages)
+- [x] CHK021 Does the spec define what may be stored in run `context` and require it to be safe/sanitized (no secrets/PII)? [Privacy] Evidence: `spec.md` §FR-001 (“Context safety”)
+- [x] CHK022 Are Monitoring render-time constraints explicit (DB-only; no external calls; no remote fetches at render time)? [Completeness] Evidence: `spec.md` §FR-017 + §FR-008
+
+## Requirement Consistency
+
+- [x] CHK023 Are the terms “status” and “outcome bucket” used consistently (and are queued/running treated consistently across the doc)? [Consistency] Evidence: `spec.md` §FR-001 (Status/Outcome semantics) + §FR-011 (Run state presentation)
+- [x] CHK024 Are run type naming rules consistent across taxonomy, Phase 1 run list, and the adoption matrix (spelling/casing)? [Consistency] Evidence: `spec.md` §FR-002 + §FR-003 + §Run vs Audit-only Adoption Matrix
+- [x] CHK025 Is restore adapter behavior described consistently across Clarifications, Scope (“Restore visibility”), FR-003, and Key Entities? [Consistency] Evidence: `spec.md` §Clarifications (restore) + §Scope & Assumptions (“Restore visibility”) + §FR-003 + §Key Entities
+- [x] CHK026 Are retention and default monitoring window expectations consistent (retention vs default list time range)? [Consistency] Evidence: `spec.md` §Clarifications (retention) + §Assumptions + §FR-004
+
+## Acceptance Criteria Quality
+
+- [x] CHK027 Are success criteria measurable and objectively verifiable without implementation details? [Measurability] Evidence: `spec.md` §SC-001–SC-004
+- [x] CHK028 Is the ≥99% dedupe target defined with a measurement scope (what counts as an attempt; “normal conditions” definition)? [Clarity] Evidence: `spec.md` §SC-003 (Measurement scope) + §FR-009
+- [x] CHK029 Is “no secrets/PII” defined with an explicit boundary sufficient for reviewers to validate completeness? [Clarity] Evidence: `spec.md` §SC-004 + §FR-013
+
+## Scenario Coverage
+
+- [x] CHK030 Are primary, forbidden, and “background unavailable” scenarios covered with explicit, testable outcomes (including “must not claim queued”)? [Coverage] Evidence: `spec.md` §User Stories 1–3 + §User Story 2 (Scenario 4) + §Edge Cases
+
+## Notes
+
+- Check items off as completed: `[x]`
+- Add findings inline (e.g., under a checklist item) with links to the relevant spec section
+- This checklist evaluates requirements quality, not implementation correctness
diff --git a/specs/054-unify-runs-suitewide/contracts/admin-pages.openapi.yaml b/specs/054-unify-runs-suitewide/contracts/admin-pages.openapi.yaml
index c3d5238..28315b7 100644
--- a/specs/054-unify-runs-suitewide/contracts/admin-pages.openapi.yaml
+++ b/specs/054-unify-runs-suitewide/contracts/admin-pages.openapi.yaml
@@ -12,7 +12,7 @@ servers:
- url: /
paths:
- /admin/t/{tenantExternalId}/bulk-operation-runs:
+ /admin/t/{tenantExternalId}/operations:
get:
operationId: monitoringOperationsIndex
summary: Monitoring → Operations (tenant-scoped)
@@ -28,7 +28,7 @@ paths:
'302':
description: Redirect to login when unauthenticated.
- /admin/t/{tenantExternalId}/bulk-operation-runs/{bulkOperationRunId}:
+ /admin/t/{tenantExternalId}/operations/{operationRunId}:
get:
operationId: monitoringOperationsView
summary: Operation run detail (tenant-scoped)
@@ -38,7 +38,7 @@ paths:
required: true
schema:
type: string
- - name: bulkOperationRunId
+ - name: operationRunId
in: path
required: true
schema:
@@ -52,4 +52,3 @@ paths:
description: Forbidden when attempting cross-tenant access.
components: {}
-
diff --git a/specs/054-unify-runs-suitewide/contracts/routes.md b/specs/054-unify-runs-suitewide/contracts/routes.md
index f139181..ec1a27d 100644
--- a/specs/054-unify-runs-suitewide/contracts/routes.md
+++ b/specs/054-unify-runs-suitewide/contracts/routes.md
@@ -3,16 +3,12 @@ # Routes & URLs
## Monitoring UI
### List Operations
-- **Route**: `tenant.monitoring.operations.index`
-- **URL**: `/tenants/{tenant}/monitoring/operations`
-- **Controller**: Livewire Component (`App\Livewire\Monitoring\OperationsList`)
+- **URL**: `/admin/t/{tenantExternalId}/operations`
+- **Surface**: Filament Resource `App\Filament\Resources\OperationRunResource` (index)
### View Operation
-- **Route**: `tenant.monitoring.operations.show`
-- **URL**: `/tenants/{tenant}/monitoring/operations/{run}`
-- **Controller**: Livewire Component (`App\Livewire\Monitoring\OperationsDetail`)
+- **URL**: `/admin/t/{tenantExternalId}/operations/{operationRunId}`
+- **Surface**: Filament Resource `App\Filament\Resources\OperationRunResource` (view)
## Deep Links
-- **Drift**: `/tenants/{tenant}/drift/history/{id}`
-- **Inventory**: `/tenants/{tenant}/inventory` (General, or specific timestamp if supported)
-- **Restore**: `/tenants/{tenant}/restore/{id}`
\ No newline at end of file
+- Use Filament URL helpers (`Resource::getUrl(...)`, `Page::getUrl(...)`) to generate tenant-scoped links back to owning feature surfaces/results.
diff --git a/specs/054-unify-runs-suitewide/contracts/service_interface.md b/specs/054-unify-runs-suitewide/contracts/service_interface.md
index b3db982..8540b5d 100644
--- a/specs/054-unify-runs-suitewide/contracts/service_interface.md
+++ b/specs/054-unify-runs-suitewide/contracts/service_interface.md
@@ -20,6 +20,10 @@ ### `ensureRun`
3. If found, return it.
4. If not found, create new `queued` run.
5. Return run.
+ 6. If an existing active run is returned (dedupe), the initiator (`user_id`, `initiator_name`) MUST NOT be replaced.
+
+- **Dispatch failure**:
+ - If queue dispatch fails after a run was created, the system MUST NOT leave misleading queued runs; instead complete the run immediately as `failed` (e.g., failure code `queue.dispatch_failed`) and show a clear UI message.
### `updateRun`
Updates the status/outcome of a run.
@@ -44,5 +48,6 @@ ### `failRun`
## `App\Jobs\Middleware\TrackOperationRun`
Middleware for Jobs to automatically handle `running` -> `completed`/`failed` transitions if bound to a run.
-## `App\Listeners\SyncRestoreRunToOperation`
-Listener for `RestoreRun` events to update the shadow `OperationRun`.
\ No newline at end of file
+## `App\Listeners\SyncRestoreRunToOperationRun`
+Listener for `RestoreRun` events to update the shadow `OperationRun`.
+The adapter row is created/visible only once a restore run reaches `previewed` (or later).
diff --git a/specs/054-unify-runs-suitewide/data-model.md b/specs/054-unify-runs-suitewide/data-model.md
index 589e6ac..bc18a04 100644
--- a/specs/054-unify-runs-suitewide/data-model.md
+++ b/specs/054-unify-runs-suitewide/data-model.md
@@ -16,7 +16,7 @@ ### `OperationRun`
| `outcome` | String | Yes | Result bucket: `pending`, `succeeded`, `partially_succeeded`, `failed`, `cancelled`. |
| `run_identity_hash` | String | Yes | Deterministic hash for idempotency. |
| `summary_counts` | JSONB | No | `{ "total": 10, "success": 8, "failed": 2, "skipped": 0 }` |
-| `failure_summary` | JSONB | No | List of sanitized errors: `[{ "code": "GraphError", "message": "Throttled", "count": 1 }]` |
+| `failure_summary` | JSONB | No | List of sanitized errors: `[{ "code": "graph.throttled", "message": "Throttled (retrying)", "count": 1 }]` |
| `context` | JSONB | No | Run-specific metadata. e.g., `{ "restore_run_id": 123, "selection": [...] }` |
| `started_at` | Timestamp | No | When execution began. |
| `completed_at` | Timestamp | No | When execution finished. |
@@ -31,7 +31,9 @@ ### `OperationRun`
### `RestoreRun` (Existing)
Remains the domain source of truth for Restore.
- Linked via `OperationRun.context['restore_run_id']`.
-- `OperationRun` mirrors `RestoreRun` status/outcome.
+- Adapter row is created/visible only once `RestoreRunStatus=previewed` (or later).
+ - When `RestoreRunStatus=previewed`, the adapter uses `OperationRun.status=queued` and `OperationRun.outcome=pending`.
+- `OperationRun` mirrors the restore execution lifecycle for Monitoring visibility (restore domain history remains owned by `RestoreRun`).
## Enums
@@ -45,7 +47,26 @@ ### `OperationRunOutcome`
- `succeeded`
- `partially_succeeded`
- `failed`
-- `cancelled`
+- `cancelled` (reserved/future; MUST NOT be produced by 054)
+
+**UI label mapping** (display-only):
+
+- `pending` → “Pending”
+- `succeeded` → “Succeeded”
+- `partially_succeeded` → “Partially succeeded”
+- `failed` → “Failed”
+- `cancelled` → “Cancelled” (reserved)
+
+## State Transitions
+
+`OperationRun.status` transitions:
+
+- `queued` → `running` → `completed`
+
+`OperationRun.outcome` transitions:
+
+- `pending` while `status` is `queued` or `running`
+- one of `succeeded`, `partially_succeeded`, `failed`, `cancelled` when `status` is `completed`
## Relationships
- `OperationRun` belongs to `Tenant`.
diff --git a/specs/054-unify-runs-suitewide/plan.md b/specs/054-unify-runs-suitewide/plan.md
index 0a09e67..3df874d 100644
--- a/specs/054-unify-runs-suitewide/plan.md
+++ b/specs/054-unify-runs-suitewide/plan.md
@@ -1,76 +1,98 @@
-# Implementation Plan: Unified Operations Runs Suitewide
+# Implementation Plan: Unified Operations Runs Suitewide (054)
-**Branch**: `feat/054-unify-operations-runs-suitewide` | **Date**: 2026-01-16 | **Spec**: [Spec Link](spec.md)
-**Input**: Feature specification from `specs/054-unify-runs-suitewide/spec.md`
+**Branch**: `feat/054-unify-operations-runs-suitewide` | **Date**: 2026-01-17 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/054-unify-runs-suitewide/spec.md` ([spec.md](spec.md))
+**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/054-unify-runs-suitewide/spec.md`
+
+**Note**: This plan is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
-This feature unifies long-running tenant operations (e.g., Inventory Sync, Drift Generation) into a single canonical `operation_runs` table. This enables a consistent "Monitoring -> Operations" view for all tenant activities. Legacy run tables will be maintained in parallel for now (Parallel Write Transition). `RestoreRun` remains a domain-specific record but will be mirrored into `operation_runs` via an adapter pattern.
+Eliminate “run sprawl” by adopting a single tenant-scoped canonical run record (`operation_runs`) for long-running and
+operationally relevant actions across the product, surfaced consistently in Monitoring → Operations (list + detail).
+
+Key behaviors:
+- Start surfaces are enqueue-only: authorize → create/reuse canonical run (dedupe) → dispatch → confirm + “View run”.
+- Legacy per-module run tables remain in parallel where they exist; Monitoring/Operations uses canonical runs.
+- Restore remains a domain workflow record, but is mirrored into canonical runs via an adapter row (`restore.execute`)
+ created from `RestoreRunStatus=previewed` onward (`status=queued`, `outcome=pending` until execution begins).
## Technical Context
-**Language/Version**: PHP 8.4
-**Primary Dependencies**: Filament v4, Laravel v12, Livewire v3
-**Storage**: PostgreSQL (`operation_runs` table + JSONB)
-**Testing**: Pest v4 (Feature tests for Service, Livewire tests for UI)
-**Target Platform**: Linux server (Docker/Dokploy)
-**Project Type**: Web Application (Laravel Monolith)
-**Performance Goals**: Start operation < 2s. List runs < 200ms.
-**Constraints**: Tenant isolation is paramount. No cross-tenant data leakage.
-**Scale/Scope**: ~50-100 runs/day per tenant. Retention 90 days.
+**Language/Version**: PHP 8.4.15 (Laravel 12)
+**Primary Dependencies**: Filament v4, Livewire v3
+**Storage**: PostgreSQL (`operation_runs` + JSONB for summary/failures/context; partial unique index for active-run dedupe)
+**Testing**: Pest v4 (PHPUnit v12)
+**Target Platform**: Web application (Sail-first locally, Dokploy-first deploy)
+**Project Type**: web
+**Performance Goals**: Start surfaces confirm within 2 seconds and provide a “View run” link; Monitoring/Operations list is usable with default last-30-days window and filters.
+**Constraints**: Tenant isolation is non-negotiable; Monitoring is DB-only at render time; no remote work inline; failures are stable codes + sanitized messages (no secrets/tokens/raw payload dumps).
+**Scale/Scope**: Tenant-scoped run history across modules; retention defaults to 90 days.
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
-- [x] Inventory-first: N/A (this is about tracking operations, not inventory state itself)
-- [x] Read/write separation: Monitoring is read-only. Starts are explicit writes.
-- [x] Graph contract path: N/A (this feature tracks runs, doesn't call Graph directly)
-- [x] Deterministic capabilities: N/A
-- [x] Tenant isolation: `operation_runs` has `tenant_id`. Policies ensure scope.
-- [x] Automation: Idempotency enforced via DB index.
-- [x] Data minimization: No secrets in `failure_summary`.
+- Inventory-first: PASS (Monitoring uses persisted run records; does not redefine Inventory semantics).
+- Read/write separation: PASS (Monitoring/Operations is view-only; writes remain in their owning UIs with explicit confirmation/audit where applicable).
+- Graph contract path: PASS (Monitoring makes no Graph calls; start surfaces MUST NOT perform remote work inline).
+- Deterministic capabilities: N/A (no new capability resolver introduced in this feature).
+- Tenant isolation: PASS (all run access is tenant-scoped; cross-tenant access is forbidden).
+- Run observability: PASS (queued/remote/scheduled work is tracked as canonical runs and links to a single Monitoring hub).
+- Automation: PASS (active-run de-duplication via deterministic identity + partial unique index).
+- Data minimization: PASS (failure summaries are sanitized and stable; no secrets/tokens/raw payload dumps in persisted failures/notifications).
+
+**Gate status (pre-Phase 0)**: PASS (no violations).
+**Gate status (post-Phase 1)**: PASS (design artifacts present: `research.md`, `data-model.md`, `contracts/`, `quickstart.md`).
## Project Structure
### Documentation (this feature)
```text
-specs/054-unify-runs-suitewide/
-├── plan.md # This file
-├── research.md # Research findings
-├── data-model.md # Database schema
-├── quickstart.md # Dev guide
-├── contracts/ # Service interfaces & routes
-└── tasks.md # Task breakdown
+/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/054-unify-runs-suitewide/
+├── plan.md # This file (/speckit.plan command output)
+├── spec.md # Feature specification (input)
+├── checklists/
+│ └── requirements.md # Spec quality checklist
+├── research.md # Phase 0 output
+├── data-model.md # Phase 1 output
+├── quickstart.md # Phase 1 output
+├── contracts/ # Phase 1 output
+└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
```
### Source Code (repository root)
```text
app/
-├── Models/
-│ └── OperationRun.php
-├── Services/
-│ └── OperationRunService.php
-├── Livewire/
-│ └── Monitoring/
-│ ├── OperationsList.php
-│ └── OperationsDetail.php
+├── Filament/
+│ ├── Pages/
+│ └── Resources/
├── Jobs/
│ └── Middleware/
-│ └── TrackOperationRun.php
-└── Listeners/
- └── SyncRestoreRunToOperation.php
+├── Listeners/
+├── Models/
+├── Notifications/
+├── Observers/
+├── Policies/
+├── Services/
+└── Support/
-database/migrations/
-└── YYYY_MM_DD_create_operation_runs_table.php
+database/
+└── migrations/
+
+routes/
+└── console.php
+
+tests/
+├── Feature/
+└── Unit/
```
-**Structure Decision**: Standard Laravel Service/Model/Livewire pattern.
+**Structure Decision**: Laravel web application. Implement canonical runs via Eloquent (`OperationRun`) + a small service layer for idempotent creation and lifecycle updates, instrument background jobs via middleware, and surface runs in Filament Monitoring/Operations (tenant-scoped, view-only).
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
-| None | | |
\ No newline at end of file
+| None | | |
diff --git a/specs/054-unify-runs-suitewide/quickstart.md b/specs/054-unify-runs-suitewide/quickstart.md
index 9a99384..eaf8e35 100644
--- a/specs/054-unify-runs-suitewide/quickstart.md
+++ b/specs/054-unify-runs-suitewide/quickstart.md
@@ -1,7 +1,7 @@
# Quickstart: Adding a New Operation
## 1. Register Run Type
-Add your new type constant to `App\Enums\OperationRunType` (if using Enums) or just use the string convention `resource.action`.
+Add your new type constant to `App\Support\OperationRunType` (if using Enums) or just use the string convention `resource.action`.
## 2. Implement Idempotency Inputs
Define what makes a run "unique" for your feature.
@@ -16,34 +16,36 @@ ## 3. Use `OperationRunService`
// 2. Dispatch Job (if new)
if ($run->wasRecentlyCreated) {
- MyJob::dispatch($run, $inputs);
+ $service->dispatchOrFail($run, function () use ($run, $inputs): void {
+ MyJob::dispatch($run, $inputs);
+ });
}
// 3. Return View Link
-return redirect()->route('tenant.monitoring.operations.show', [$tenant, $run]);
+return redirect(\App\Support\OperationRunLinks::viewUrl($tenant, $run));
```
## 4. Instrument Job
In your Job:
```php
-public function handle()
+public function handle(\App\Services\OperationRunService $service)
{
- // Update to Running
- $this->run->updateStatus(status: 'running');
-
try {
// ... do work ...
// Success
- $this->run->updateStatus(
- status: 'completed',
- outcome: 'succeeded',
- summary: ['processed' => 100]
- );
+ $service->updateRun($this->run, status: 'completed', outcome: 'succeeded', summaryCounts: [
+ 'processed' => 100,
+ ]);
} catch (\Throwable $e) {
// Failure
- $this->run->fail($e);
+ $service->failRun($this->run, $e);
}
}
```
+
+## Notes: Monitoring & Run-Standard (Graph safety)
+
+- **RBAC Wizard** (`TenantResource`): group search is **delegated-Graph-based** and the picker is **disabled without a delegated token**.
+- **Restore Wizard** (`RestoreRunResource`): group mapping stays **DB-only** (Directory Cache / `entra_groups`) during the mapping phase — **no Graph calls** there; fallback helper text is always visible.
diff --git a/specs/054-unify-runs-suitewide/research.md b/specs/054-unify-runs-suitewide/research.md
index 679092c..0a50c51 100644
--- a/specs/054-unify-runs-suitewide/research.md
+++ b/specs/054-unify-runs-suitewide/research.md
@@ -4,9 +4,11 @@ ## 1. Technical Context & Unknowns
**Unknowns Resolved**:
- **Transition Strategy**: Parallel write. We will maintain existing legacy tables (e.g., `inventory_sync_runs`, `restore_runs`) for now but strictly use `operation_runs` for the Monitoring UI.
-- **Restore Adapter**: `RestoreRun` remains the domain source of truth. An `OperationRun` record will be created as a "shadow" or "adapter" record. This requires hooking into `RestoreRun` lifecycle events or the service layer to keep them in sync.
+- **Restore Adapter**: `RestoreRun` remains the domain source of truth. An `OperationRun` adapter row will be created once a restore run reaches `previewed` (and later statuses), and will be kept in sync via `RestoreRun` lifecycle events or service-layer wrapping.
- **Run Logic Location**: Existing jobs like `RunInventorySyncJob` will be updated to manage the `OperationRun` state.
- **Concurrency**: Enforced by partial unique index on `(tenant_id, run_identity_hash)` where status is active (`queued`, `running`).
+- **Dispatch Failure Semantics**: If queue dispatch fails, the system will immediately complete the run as `failed` (e.g., `queue.dispatch_failed`) and show a clear UI message (never leaving misleading queued runs).
+- **Notifications on Dedupe**: Only the original initiator (`operation_runs.user_id`) receives queued/terminal notifications; reusers of an active run do not get additional notifications.
## 2. Technology Choices
@@ -22,16 +24,24 @@ ## 3. Implementation Patterns
### Canonical Run Lifecycle
1. **Start Request**:
- Compute `run_identity_hash` from inputs.
- - Attempt `INSERT` into `operation_runs` (ignore conflict if active).
- - If active run exists, return it (Idempotency).
- - If new, dispatch Job.
+ - Attempt `INSERT` into `operation_runs` (idempotent; enforced by partial unique index for active runs).
+ - If an active run exists, return it (Idempotency).
+ - If new, dispatch the background Job.
+ - If dispatch fails, immediately mark the run `status=completed`, `outcome=failed` with a safe failure code such as `queue.dispatch_failed`.
2. **Job Execution**:
- Update status to `running`.
- Perform work.
- - Update status to `succeeded`/`failed`.
+ - Update status to `completed` and set terminal outcome (`succeeded` / `partially_succeeded` / `failed` / `cancelled`).
3. **Restore Adapter**:
- - When `RestoreRun` is created, create `OperationRun` (queued/running).
- - When `RestoreRun` updates (status change), update `OperationRun`.
+ - Create the adapter row only once `RestoreRunStatus=previewed` (or later) is reached.
+ - Map `RestoreRunStatus=previewed` to `OperationRun.status=queued` and `OperationRun.outcome=pending`.
+ - Keep the adapter updated as the restore progresses:
+ - `queued` → `status=queued`, `outcome=pending`
+ - `running` → `status=running`, `outcome=pending`
+ - `completed` → `status=completed`, `outcome=succeeded`
+ - `partial` → `status=completed`, `outcome=partially_succeeded`
+ - `failed` → `status=completed`, `outcome=failed`
+ - `cancelled` → `status=completed`, `outcome=cancelled`
### Data Model
```sql
@@ -63,3 +73,5 @@ ## 4. Risks & Mitigations
- **Mitigation**: Use model observers or service-layer wrapping to ensure atomic-like updates, or accept slight eventual consistency (Monitoring might lag ms behind Restore UI).
- **Risk**: Legacy runs not appearing.
- **Mitigation**: We are NOT backfilling legacy runs. Only new runs after deployment will appear in the new Monitoring UI. This is acceptable for "Phase 1".
+- **Risk**: Confusion about `queued` for restore `previewed`.
+ - **Mitigation**: Document that `restore.execute` appears from `previewed` onward and uses `queued/pending` until execution begins; Monitoring remains view-only and links to the restore domain detail.
diff --git a/specs/054-unify-runs-suitewide/spec.md b/specs/054-unify-runs-suitewide/spec.md
index b54f7a3..c97330f 100644
--- a/specs/054-unify-runs-suitewide/spec.md
+++ b/specs/054-unify-runs-suitewide/spec.md
@@ -12,9 +12,19 @@ ### Session 2026-01-16
- Q: Welche Default-Retention soll 054 für canonical Operation Runs festlegen? → A: 90 days
- Q: Transition-Strategie in 054: schreiben wir canonical Runs parallel zu Legacy-Run-Tabellen, oder ersetzen wir sofort? → A: Parallel write (canonical + legacy)
- Q: For `restore.execute`, the spec mentions it acts as an "adapter entry" linking to the restore domain record. How should this be implemented? → A: Physical Row (Create a physical row in `operation_runs` that points to the restore record).
-- Q: How should concurrency and deduplication (FR-009) be enforced at the database level? → A: Partial Unique Index (unique constraint on `tenant_id, run_identity_hash` where outcome is `queued` or `running`).
+- Q: How should concurrency and deduplication (FR-009) be enforced at the database level? → A: Partial Unique Index (unique constraint on `tenant_id, run_identity_hash` where status is `queued` or `running`).
- Q: How should the `initiator` be modeled to support both users and system processes (FR-001)? → A: Nullable FK + Name Snapshot (`user_id` nullable FK + required `initiator_name` string).
+### Session 2026-01-17
+
+- Q: Sollen `backup_schedule.run_now` und `backup_schedule.retry` in 054 zur Phase-1-Adoption (must be implemented) gehören? → A: Yes — both are Phase 1 in 054 (OperationRun producers + worker tracking).
+- Q: Wenn Queue-Dispatch fehlschlägt (Background Processing unavailable), sollen wir trotzdem einen `OperationRun` anlegen und ihn sofort als fehlgeschlagen abschließen? → A: Yes — create an `OperationRun` and immediately complete it as `failed` (e.g., failure code `queue.dispatch_failed`); show a clear error and MAY include a “View run” link.
+- Q: Wenn ein Start deduped wird (Run wird wiederverwendet), wer soll die In‑App Notifications (“queued” + terminal outcome) bekommen? → A: Only the original initiator (`operation_runs.user_id`); no additional notifications are sent to the second starter on reuse.
+- Q: Für `restore.execute`: In welchen `RestoreRunStatus`-Phasen soll überhaupt ein `OperationRun`-Adapter‑Row erzeugt/angezeigt werden? → A: From `previewed` onwards (previewed + execution statuses); no adapter row for `draft`/`scoped`/`checked`.
+- Q: Wenn der `restore.execute` Adapter bereits ab `RestoreRunStatus=previewed` sichtbar ist: welchen `OperationRun`-State sollen wir für diese Phase setzen? → A: `status=queued`, `outcome=pending` (until `running`, then `completed` + terminal outcome).
+- Q: RBAC Wizard (`TenantResource`) – wie funktioniert Group Search? → A: Group search is delegated-Graph-based and the picker MUST be disabled without delegated auth.
+- Q: Restore Wizard (`RestoreRunResource`) – Group Mapping Phase: Graph oder DB-only? → A: DB-only via Directory Cache (`entra_groups`), no Graph calls during mapping; helper text is always shown (fallback included).
+
## User Scenarios & Testing *(mandatory)*
### User Story 1 - See Every Supported Operation in Monitoring (Priority: P1)
@@ -27,10 +37,10 @@ ### User Story 1 - See Every Supported Operation in Monitoring (Priority: P1)
**Acceptance Scenarios**:
-1. **Given** I am signed into tenant A, **When** I open Monitoring → Operations, **Then** I see only tenant A runs and can filter by run type, outcome bucket, time range, and initiator.
+1. **Given** I am signed into tenant A, **When** I open Monitoring → Operations, **Then** I see only tenant A runs and can filter by run type, run state (queued/running/terminal outcome), time range, and initiator.
2. **Given** multiple run types exist, **When** I filter to `inventory.sync`, **Then** only inventory sync runs are shown.
-3. **Given** a run exists, **When** I open its detail view, **Then** I can see initiator, run type, outcome bucket, timestamps, summary counts (if applicable), sanitized failures (if any), and links to relevant feature context/results.
-4. **Given** restore execution exists, **When** I open Monitoring → Operations, **Then** I can see a `restore.execute` entry that links to the existing restore record (restore history remains owned by the restore domain record).
+3. **Given** a run exists, **When** I open its detail view, **Then** I can see initiator, run type, run state (queued/running/terminal outcome), timestamps, summary counts (if applicable), sanitized failures (if any), and links to relevant feature context/results.
+4. **Given** a restore run has reached `previewed` or later, **When** I open Monitoring → Operations, **Then** I can see a `restore.execute` entry that links to the existing restore record (restore history remains owned by the restore domain record).
5. **Given** I am a `Readonly` user in tenant A, **When** I view Monitoring → Operations, **Then** I can view runs and details but I do not see any start/rerun/cancel/delete controls.
6. **Given** I attempt to access a run from another tenant (direct link or list), **When** I request it, **Then** access is denied and no run details are disclosed.
@@ -50,6 +60,7 @@ ### User Story 2 - Start Operations Without Blocking (Priority: P2)
2. **Given** I am a `Readonly` user in tenant A, **When** I attempt to start any Phase 1 operation, **Then** the system denies the request and does not create a new run.
3. **Given** the run reaches a terminal outcome, **When** that occurs, **Then** the initiating user receives an in-app notification including a short summary and a “View run” link.
4. **Given** background processing is unavailable, **When** I attempt to start an operation, **Then** I receive a clear message and the system MUST NOT claim it was queued.
+ - If an `OperationRun` record was created during the attempt, it MUST be completed immediately with outcome `failed` (never left `queued`) and MAY be linked via “View run”.
---
@@ -68,7 +79,7 @@ ### User Story 3 - Duplicate Starts Reuse the Same Active Run (Priority: P3)
### Edge Cases
-- Background execution unavailable: start fails fast with a clear message; the system MUST NOT create misleading “queued” runs.
+- Background execution unavailable: start fails fast with a clear message; if an `OperationRun` record was created, it MUST be immediately completed as `failed` (e.g., `queue.dispatch_failed`) and MUST NOT be left `queued`.
- Partial processing: at least one success and at least one failure yields “partially succeeded”, with per-item failures when applicable.
- Large run history: Monitoring remains usable with filters and defaults (recent runs, last 30 days).
- Permissions revoked mid-run: the run continues; visibility is evaluated at time of access.
@@ -87,10 +98,14 @@ ### Scope & Assumptions
- `directory_groups.sync` (Directory → Groups “Sync groups”)
- `drift.generate` (Drift “Generate drift now” / auto-on-open when eligible)
- `backup_set.add_policies` (Backup Sets “Add selected” / “Add policies”)
+- `backup_schedule.run_now` (Backup Schedules “Run now”)
+- `backup_schedule.retry` (Backup Schedules “Retry”)
**Restore visibility (adapter only):**
- `restore.execute` appears as a canonical run entry that links to an existing restore domain record.
+- The adapter row MUST be created/visible only once a restore run reaches `previewed` (or later) and MUST NOT be created for `draft`, `scoped`, or `checked`.
+- When the restore run is `previewed`, the adapter `OperationRun` MUST use `status=queued` and `outcome=pending`.
- Restore execution history remains owned by the restore domain record (not replaced in Phase 1).
**Out of scope for 054 (explicit):**
@@ -100,6 +115,7 @@ ### Scope & Assumptions
- Cancel/rerun/delete controls inside Monitoring hub (hub stays view-only)
- Replacing restore domain records with canonical runs
- A full settings UI for retention/notifications/etc.
+- Implementing or validating `AuditLog` behavior for audit-only actions (FR-019) beyond actions explicitly changed by 054
**Assumptions (defaults to remove ambiguity in Phase 1):**
@@ -107,35 +123,104 @@ ### Scope & Assumptions
- System-initiated runs (if any) do not notify users by default in Phase 1.
- Transition strategy: write canonical runs in parallel with any existing legacy per-module run tables (where they exist); Monitoring uses canonical runs as the source of truth immediately.
+**Run vs Audit-only Adoption Matrix (Phase 1):**
+
+| Feature Area | Action | Tracking | run_type / audit action |
+|-------------|--------|----------|--------------------------|
+| Policies | Sync now | OperationRun | `policy.sync` |
+| Policies | Ignore policy | Audit-only | `policy.ignore` |
+| Policies | Export to backup | OperationRun (queued) | `policy.export_backup` |
+| Policy Versions | Capture snapshot | OperationRun | `policy.capture_snapshot` |
+| Policy Versions | Prune versions | Audit-only | `policy_versions.prune` |
+| Policy Versions | Archive versions | Audit-only | `policy_versions.archive` |
+| Inventory | Sync now | OperationRun | `inventory.sync` |
+| Directory Groups | Sync groups | OperationRun | `directory_groups.sync` |
+| Drift | Generate drift | OperationRun | `drift.generate` |
+| Backup Sets | Add policies | OperationRun | `backup_set.add_policies` |
+| Backup Sets | Archive | Audit-only (DB-only) | `backup_set.archive` |
+| Backup Sets | Restore (bulk) | OperationRun | `backup_set.restore` |
+| Backup Sets | Force delete | Audit-only (admin-only) | `backup_set.force_delete` |
+| Backup Schedules | Run now | OperationRun | `backup_schedule.run_now` |
+| Backup Schedules | Retry | OperationRun | `backup_schedule.retry` |
+| Backup Schedules | Edit | Audit-only | `backup_schedule.edit` |
+| Backup Schedules | Delete | Audit-only | `backup_schedule.delete` |
+| Tenants | Sync tenant | OperationRun | `tenant.sync` |
+| Tenants | Admin consent | Audit-only | `tenant.admin_consent` |
+| Tenants | Verify configuration | Audit-only | `tenant.verify_config` |
+| Tenants | Setup Intune RBAC | Audit-only | `tenant.setup_rbac` |
+| Tenants | Deactivate | Audit-only | `tenant.deactivate` |
+| Restore | Execute restore | OperationRun (adapter) | `restore.execute` (context → `restore_run_id`) |
+
+**Rule**: If an action is queued/background, long-running, or requires remote/external calls (e.g., Microsoft Graph),
+it MUST be tracked as an OperationRun. Only fast DB-only changes MAY be Audit-only.
+
### Functional Requirements
-- **FR-001 Canonical Operation Run**: System MUST represent each supported operation execution as a canonical, tenant-scoped operation run record that captures initiator (nullable `user_id` FK + `initiator_name` string), run type, lifecycle status/timestamps, outcome bucket, summary counts (when applicable), safe failure summaries, an idempotency identity for dedupe, and a safe context payload referencing “what this run was about”.
+- **FR-001 Canonical Operation Run**: System MUST represent each supported operation execution as a canonical, tenant-scoped operation run record that captures initiator (nullable `user_id` FK + `initiator_name` string), run type, lifecycle status/timestamps, terminal outcome (pending while active), summary counts (when applicable), safe failure summaries, an idempotency identity for dedupe, and a safe context payload referencing “what this run was about”.
+ - **Status semantics**: `status` represents lifecycle stage (`queued` → `running` → `completed`).
+ - **Outcome semantics (stored tokens)**: `outcome` stores machine tokens: `pending` while active, otherwise `succeeded` / `partially_succeeded` / `failed`.
+ - **UI labels**: Monitoring displays human labels derived from stored tokens (e.g., `partially_succeeded` → “Partially succeeded”).
+ - **Reserved**: `cancelled` is reserved for future use and MUST NOT be produced by 054 (Monitoring hub has no cancel controls).
+ - **Context safety**: `context` MUST be sanitized and MUST include only safe references (e.g., stable IDs, selection scope keys, correlation IDs). It MUST NOT include secrets/tokens/credentials, personal data, or full external payload dumps.
- **FR-002 Run taxonomy**: Run type MUST be stable and follow `"
."`.
-- **FR-003 Phase 1 run types**: Phase 1 run types MUST include `inventory.sync`, `policy.sync`, `directory_groups.sync`, `drift.generate`, `backup_set.add_policies`, plus `restore.execute` implemented as a physical `operation_runs` record (adapter) pointing to the domain entity.
-- **FR-004 Monitoring lists all canonical runs**: Monitoring → Operations MUST list canonical runs for the active tenant with filters for run type, outcome bucket, time range, and initiator; default sort is most recent first; default time window is last 30 days.
-- **FR-005 Run detail**: Run detail MUST show initiator, run type, outcome bucket, timestamps (created/started/finished), summary counts (when applicable), sanitized failures (including per-item failures when applicable), and contextual links to owning feature surfaces/results.
+- **FR-003 Phase 1 run types**: Phase 1 run types MUST include `inventory.sync`, `policy.sync`, `directory_groups.sync`, `drift.generate`, `backup_set.add_policies`, `backup_schedule.run_now`, `backup_schedule.retry`, plus `restore.execute` implemented as a physical `operation_runs` record (adapter) pointing to the domain entity.
+- **FR-004 Monitoring lists all canonical runs**: Monitoring → Operations MUST list canonical runs for the active tenant with filters for run type, run state (queued/running/terminal outcome), time range, and initiator; default sort is most recent first; default time window is last 30 days.
+- **FR-005 Run detail**: Run detail MUST show initiator, run type, run state (queued/running/terminal outcome), timestamps (created/started/finished), summary counts (when applicable), sanitized failures (including per-item failures when applicable), and contextual links to owning feature surfaces/results.
- **FR-006 View-only hub**: Monitoring hub MUST be view-only (no start/rerun/cancel/delete controls) and MUST link back to owning feature surfaces.
- **FR-007 Start surfaces always enqueue**: Every Phase 1 start surface MUST authorize start, create/reuse a canonical run (dedupe), dispatch background execution, and return immediately with confirmation + “View run”.
- **FR-008 No remote work in interactive request**: Start surfaces MUST NOT perform remote work inline; long-running work happens in background execution.
-- **FR-009 Deterministic idempotency**: For each run type, the system MUST define a deterministic identity for “identical run” based on tenant + effective inputs; initiator MUST NOT be part of identity. **Enforcement**: Uniqueness MUST be enforced via a partial unique index on `(tenant_id, run_identity_hash)` where outcome is `queued` or `running`.
+- **FR-009 Deterministic idempotency**: For each run type, the system MUST define a deterministic identity for “identical run” based on tenant + effective inputs; initiator MUST NOT be part of identity. **Enforcement**: Uniqueness MUST be enforced via a partial unique index on `(tenant_id, run_identity_hash)` where status is `queued` or `running`.
- **FR-010 Phase 1 identity rules**: Identity rules MUST be defined at least as follows:
- `inventory.sync`: tenant + selection scope
- `policy.sync`: tenant + effective policy scope
- `directory_groups.sync`: tenant + selection (Phase 1 default: “all groups”)
- `backup_set.add_policies`: tenant + backup set + selected policies + option flags (if exposed)
+ - `backup_schedule.run_now`: tenant + backup schedule id
+ - `backup_schedule.retry`: tenant + backup schedule id
- `drift.generate`: tenant + scope key + baseline/current comparison inputs
-- **FR-011 Outcome buckets**: Monitoring MUST present consistent outcome buckets: `queued`, `running`, `succeeded`, `partially succeeded`, `failed`.
-- **FR-012 Partial vs failed**: “Partially succeeded” means at least one success and at least one failure; “Failed” means zero successes or cannot proceed.
+- **FR-011 Run state presentation**: Monitoring MUST present a consistent run state using a single display bucket derived from lifecycle status and terminal outcome:
+ - If status is `queued` or `running`, display that status.
+ - If status is `completed`, display the terminal outcome derived from the stored token (`succeeded`, `partially_succeeded`, or `failed`) using the UI label mapping.
+- **FR-012 Partial vs failed (terminal outcomes)**: “Partially succeeded” (`partially_succeeded`) means at least one success and at least one failure; “Failed” (`failed`) means zero successes or cannot proceed.
- **FR-013 Failure details are safe + useful**: Failures MUST be persisted and displayed as stable reason codes and short sanitized messages; failures MUST NOT include secrets/tokens/credentials/PII or full external payload dumps.
+ - **Reason codes** MUST be stable, machine-readable identifiers (lowercase, dot-separated), e.g. `graph.throttled`, `auth.forbidden`, `validation.invalid_input`, `unexpected.exception`.
+ - **Messages** MUST be short (≤ 200 characters), sanitized, and written for operators (no secrets/tokens/credentials/PII; no raw external payloads). If needed, messages MAY include a non-sensitive correlation identifier.
- **FR-014 Related links**: Run detail MUST include contextual links where applicable (e.g., drift findings, backup set, inventory results, directory groups, restore detail for `restore.execute`).
- **FR-015 Notifications**: System MUST emit in-app notifications for “queued” (after start) and terminal outcomes for Phase 1 runs; notifications MUST include a short summary and a “View run” link; recipients are the initiating user only.
+ - If a start request reuses an existing active run (dedupe), the run initiator (as stored on the `OperationRun`) remains the sole notification recipient; the second starter receives no additional notifications.
- **FR-016 Tenant isolation**: All run list/detail access MUST be tenant-scoped; cross-tenant access MUST be denied without disclosing run details.
- **FR-017 No render-time remote calls**: Monitoring pages MUST be render-safe and MUST NOT depend on external service calls during render.
- **FR-018 Roles & permissions**: Roles `Owner`, `Manager`, `Operator`, and `Readonly` MUST be able to view runs; only `Owner`, `Manager`, `Operator` may start operations; `Readonly` is strictly view-only.
+- **FR-019 Audit-only actions (no OperationRun)**: Actions that are DB-only and complete within ≤2 seconds under normal
+ conditions MAY be executed without an OperationRun, as long as they do not start long-running background execution and
+ do not require any remote/external calls.
+ - **054 scope note**: 054 does not implement or modify audit-only actions. If any audit-only action is touched as part
+ of implementing 054 in the future, it MUST comply with this requirement and MUST be covered by tests.
+ If such an action is security-relevant or changes operational behavior (e.g., “Ignore policy”, “Deactivate tenant”,
+ “Admin consent”, “Prune versions”, “Force delete”), it MUST write exactly one tenant-scoped AuditLog entry with, at minimum:
+ - `tenant_id`
+ - `actor_user_id`
+ - `action` (stable action identifier, e.g., `policy.ignore`)
+ - `target_type`, `target_id`
+ - `before` / `after` (sanitized JSON) **or** `diff` (sanitized JSON)
+ - `created_at`
+ **Trigger guidance (to make classification reviewable)**:
+ - “Security-relevant” includes actions that grant/revoke access, change authorization posture, change admin consent, or otherwise modify who/what can read/write tenant data.
+ - “Operational behavior change” includes actions that change what the system will do in future runs (e.g., ignore/exclude resources, enable/disable schedules, retention/prune/archive actions, force deletes).
+ - If unclear whether an Audit-only action is security/ops-relevant, the default is to treat it as such and write an AuditLog entry.
+ **Sanitization (AuditLog before/after/diff)**:
+ - AuditLog payloads MUST include only the minimum fields needed to understand the change.
+ - AuditLog payloads MUST NOT include secrets/tokens/credentials, personal data, or full external payload dumps.
+ - If a field is sensitive, it MUST be omitted or replaced with a non-sensitive placeholder (e.g., `"[REDACTED]"`).
+ Monitoring/Operations remains reserved for OperationRun-tracked long-running/queued operations.
+ **Acceptance checks (testable)**:
+ - Audit-only action creates no OperationRun.
+ - Audit-only action creates exactly one AuditLog event containing the required fields.
+ - Audit-only action is tenant-scoped; cross-tenant access is forbidden and MUST NOT create AuditLog entries.
### Key Entities *(include if feature involves data)*
-- **Canonical Operation Run**: A tenant-scoped record representing the lifecycle of a long-running operation, including run type, initiator (nullable `user_id` FK + `initiator_name` string), lifecycle state/timestamps, outcome bucket, summary counts, safe failure summaries, idempotency identity (uniqueness enforced by DB index on active runs), and safe context references.
+- **Canonical Operation Run**: A tenant-scoped record representing the lifecycle of a long-running operation, including run type, initiator (nullable `user_id` FK + `initiator_name` string), lifecycle state/timestamps, terminal outcome, summary counts, safe failure summaries, idempotency identity (uniqueness enforced by DB index on active runs), and safe context references.
- **Restore domain record (exception)**: Restore remains a domain workflow record with richer semantics and history. Monitoring shows restore activity through a physical `operation_runs` row (adapter) that links back to the restore record, without replacing it.
## Success Criteria *(mandatory)*
@@ -145,4 +230,5 @@ ### Measurable Outcomes
- **SC-001**: Operators can answer “what ran, when, and did it succeed?” for any Phase 1 run in under 1 minute using Monitoring → Operations.
- **SC-002**: Starting a Phase 1 operation returns confirmation + “View run” link within 2 seconds under normal conditions.
- **SC-003**: Duplicate starts reuse the same active run in at least 99% of attempts under normal conditions.
+- **SC-003 Measurement scope (definition)**: An “attempt” counts when a start request is made for an operation with identical effective inputs while an identical run is already `queued` or `running`. The success condition is that the system reuses the existing active run reference rather than creating a second active run. “Normal conditions” exclude infrastructure outages (e.g., database unavailable) that prevent either run creation or dedupe evaluation.
- **SC-004**: No secrets/tokens/credentials/PII appear in persisted failures or notifications (verified by tests).
diff --git a/specs/054-unify-runs-suitewide/tasks.md b/specs/054-unify-runs-suitewide/tasks.md
index fd8b909..41f51b7 100644
--- a/specs/054-unify-runs-suitewide/tasks.md
+++ b/specs/054-unify-runs-suitewide/tasks.md
@@ -1,64 +1,209 @@
-# Tasks: Unified Operations Runs Suitewide
+---
+description: "Task list for feature implementation"
+---
-**Feature**: `054-unify-runs-suitewide`
-**Spec**: `specs/054-unify-runs-suitewide/spec.md`
+# Tasks: Unified Operations Runs Suitewide (054)
-## Phase 1: Foundation (DB & Service)
+**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/054-unify-runs-suitewide/`
+**Prerequisites**: `specs/054-unify-runs-suitewide/plan.md`, `specs/054-unify-runs-suitewide/spec.md`, `specs/054-unify-runs-suitewide/data-model.md`, `specs/054-unify-runs-suitewide/research.md`, `specs/054-unify-runs-suitewide/quickstart.md`, `specs/054-unify-runs-suitewide/contracts/`
-- [ ] **Migration**: Create `operation_runs` table with partial unique index on `(tenant_id, run_identity_hash)` where status in `queued, running`.
-- [ ] **Model**: Create `OperationRun` model with casts (JSONB for summaries/context), relationship to `Tenant` and `User`.
-- [ ] **Service**: Implement `OperationRunService::ensureRun()` (idempotent creation) and `updateRun()` methods.
-- [ ] **Test**: Feature test for `ensureRun` verifying idempotency (same hash = same run) and concurrency safety (simulated).
-- [ ] **Test**: Feature test for `updateRun` verifying status transitions and history logging (if any).
-- [ ] **Job Middleware**: Create `TrackOperationRun` middleware to automatically handle job success/failure updates for jobs using this system.
-- [ ] **Retention**: Create a daily scheduled job to prune `operation_runs` older than 90 days.
+**Tests**: Required (Pest) for runtime behavior changes.
+**Operations**: Long-running/queued/remote/scheduled actions MUST create/reuse a canonical `OperationRun` and link to Monitoring → Operations. Monitoring pages MUST be DB-only at render time (no remote calls).
-## Phase 2: Monitoring UI (Read-Only)
+## Format: `[ID] [P?] [Story?] Description`
-- [ ] **Page**: Create Filament Page `Monitoring/Operations` (List) strictly scoped to current tenant.
-- [ ] **Table**: Implement `OperationRun` table with columns: Status (Badge), Operation Type, Initiator, Started At, Duration, Outcome.
-- [ ] **Filters**: Add table filters for `Type`, `Outcome`, `Date Range`, `Initiator`.
-- [ ] **Detail View**: Create "View Run" modal or separate page showing:
- - Summary counts (Success/Fail/Total)
- - Failure list (Sanitized codes/messages)
- - Context JSON (Debug info)
- - Timeline (Created/Started/Finished)
-- [ ] **Test**: Livewire test verifying `Readonly` users can see table but no actions.
-- [ ] **Test**: Verify cross-tenant access is blocked.
+- **[P]**: Can run in parallel (different files, no blocking dependencies)
+- **[Story]**: User story label (e.g., `[US1]`, `[US2]`, `[US3]`) — REQUIRED for story phases only
+- Each task includes at least one concrete file path
-## Phase 3: Producer Migration (Parallel Write)
+## Path Conventions (Laravel Monolith)
-### Inventory Sync (`inventory.sync`)
-- [ ] **Refactor**: Update `RunInventorySyncJob` dispatch logic to call `OperationRunService::ensureRun()` first.
-- [ ] **Refactor**: Update Job to use `TrackOperationRun` middleware (or manual updates) to sync status to `operation_runs`.
-- [ ] **Verify**: Ensure legacy `inventory_sync_runs` is still written to (if legacy UI depends on it) OR confirm legacy UI is replaced. *Decision: Parallel write as per spec.*
+- Source: `app/`, `routes/`, `resources/`, `config/`, `database/`
+- Tests: `tests/Feature/`, `tests/Unit/`
-### Policy Sync (`policy.sync`)
-- [ ] **Refactor**: Update Policy Sync start logic to use `OperationRunService`.
-- [ ] **Refactor**: Instrument Policy Sync job to update `operation_runs`.
+---
-### Directory Groups Sync (`directory_groups.sync`)
-- [ ] **Refactor**: Update Group Sync start logic to use `OperationRunService`.
-- [ ] **Refactor**: Instrument Group Sync job to update `operation_runs`.
+## Phase 1: Setup (Spec + Contract Alignment)
-### Drift Generation (`drift.generate`)
-- [ ] **Refactor**: Update Drift Generation start logic to use `OperationRunService`.
-- [ ] **Refactor**: Instrument Drift job to update `operation_runs`.
+**Purpose**: Resolve ambiguity before implementation starts
-### Backup Set (`backup_set.add_policies`)
-- [ ] **Refactor**: Update "Add Policies" action to use `OperationRunService`.
+- [x] T001 Validate Phase 1 run type set is consistent (adoption set + FR-003 + identity rules) in `specs/054-unify-runs-suitewide/spec.md`
+- [x] T002 Validate Monitoring Operations routes match Filament surface + OpenAPI contract in `specs/054-unify-runs-suitewide/contracts/routes.md` and `specs/054-unify-runs-suitewide/contracts/admin-pages.openapi.yaml`
+- [x] T003 Validate `OperationRunService` contract + quickstart usage examples align with intended API in `specs/054-unify-runs-suitewide/contracts/service_interface.md` and `specs/054-unify-runs-suitewide/quickstart.md`
+- [x] T004 Confirm FR-019 (Audit-only) is out-of-scope for 054 unless an audit-only action is touched; if touched, add AuditLog implementation + tests per `specs/054-unify-runs-suitewide/spec.md` in `specs/054-unify-runs-suitewide/tasks.md`
-## Phase 4: Restore Adapter
+---
-- [ ] **Listener**: Create `SyncRestoreRunToOperation` listener observing `RestoreRun` events (`created`, `updated`).
-- [ ] **Logic**: Map `RestoreRun` status/outcomes to `OperationRun` schema.
- - `RestoreRun` created -> `OperationRun` created (queued/running).
- - `RestoreRun` updated -> `OperationRun` updated.
-- [ ] **Context**: Store `{"restore_run_id": }` in `OperationRun.context`.
-- [ ] **Test**: Verify creating a `RestoreRun` automatically spawns a shadow `OperationRun`.
+## Phase 2: Foundational (Canonical Run Primitive)
-## Phase 5: Notifications & Polish
+**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented
+
+- [x] T005 Create `operation_runs` migration (columns + indexes + partial unique index) in `database/migrations/*_create_operation_runs_table.php`
+- [x] T006 Create `OperationRun` Eloquent model (casts + relationships + helpers) in `app/Models/OperationRun.php`
+- [x] T007 [P] Add `OperationRunStatus` enum in `app/Support/OperationRunStatus.php`
+- [x] T008 [P] Add `OperationRunOutcome` enum (stored tokens vs UI labels; `cancelled` reserved) in `app/Support/OperationRunOutcome.php`
+- [x] T009 [P] Add `OperationRunType` enum (Phase 1 run types) in `app/Support/OperationRunType.php`
+- [x] T010 [P] Add `OperationRunFactory` for tests in `database/factories/OperationRunFactory.php`
+- [x] T011 Implement idempotent create/reuse + lifecycle updates + failure sanitization in `app/Services/OperationRunService.php` (depends on T005–T010)
+- [x] T012 Centralize “View run” + deep links in `app/Support/OperationRunLinks.php` (depends on T011)
+- [x] T013 [P] Implement tenant-scoped authorization policy in `app/Policies/OperationRunPolicy.php`
+- [x] T014 Register `OperationRun` policy with Gate in `app/Providers/AppServiceProvider.php`
+- [x] T015 Implement job middleware lifecycle tracking in `app/Jobs/Middleware/TrackOperationRun.php` (depends on T011)
+- [x] T016 Implement 90-day retention pruning job in `app/Jobs/PruneOldOperationRunsJob.php`
+- [x] T017 Schedule pruning job daily with non-overlapping lock (`withoutOverlapping` or equivalent cache lock) in `routes/console.php`
+- [x] T018 Add scheduled pruning non-overlap regression test (if feasible) in `tests/Feature/Scheduling/PruneOldOperationRunsScheduleTest.php`
+- [x] T019 Add `OperationRunService` tests (hash stability, idempotency, unique-index race, sanitization) in `tests/Feature/OperationRunServiceTest.php`
+- [x] T020 Add `TrackOperationRun` middleware lifecycle tests in `tests/Feature/TrackOperationRunMiddlewareTest.php`
+
+---
+
+## Phase 3: User Story 1 - See Every Supported Operation in Monitoring (Priority: P1)
+
+**Goal**: Monitoring → Operations shows all canonical runs (tenant-scoped) with list + detail, filters, and safe failures.
+
+**Independent Test**: Trigger at least one run of each Phase 1 run producer, then verify list/detail in Monitoring render DB-only and are tenant-scoped per `specs/054-unify-runs-suitewide/spec.md`.
+
+### Tests for User Story 1
+
+- [x] T021 [US1] Add Monitoring list/detail authorization + tenant isolation tests in `tests/Feature/MonitoringOperationsTest.php`
+- [x] T022 [P] [US1] Add Monitoring DB-only render test (mock Graph client; assert never called) in `tests/Feature/MonitoringOperationsTest.php`
+- [x] T023 [P] [US1] Add restore adapter visibility tests (created/visible from `previewed` onward; `previewed` maps to `queued/pending`) in `tests/Feature/RestoreAdapterTest.php`
+
+### Implementation for User Story 1
+
+- [x] T024 [US1] Create Filament Monitoring resource (list + view-only) in `app/Filament/Resources/OperationRunResource.php`
+- [x] T025 [US1] Implement list columns + default sort + default last-30-days window in `app/Filament/Resources/OperationRunResource.php`
+- [x] T026 [US1] Implement list filters (type, state bucket, time range, initiator) in `app/Filament/Resources/OperationRunResource.php`
+- [x] T027 [US1] Implement run detail view (meta, summary_counts, failures, context, links) in `app/Filament/Resources/OperationRunResource/Pages/ViewOperationRun.php`
+- [x] T028 [US1] Implement related links for each Phase 1 run type in `app/Support/OperationRunLinks.php`
+- [x] T029 [US1] Implement RestoreRun → OperationRun adapter create/update (from `previewed` onward; `previewed` maps to `status=queued`, `outcome=pending`) in `app/Listeners/SyncRestoreRunToOperationRun.php`
+- [x] T030 [US1] Wire RestoreRun lifecycle events to adapter in `app/Observers/RestoreRunObserver.php`
+- [x] T031 [US1] Register RestoreRun observer in `app/Providers/AppServiceProvider.php`
+
+---
+
+## Phase 4: User Story 2 - Start Operations Without Blocking (Priority: P2)
+
+**Goal**: Start surfaces are enqueue-only, return immediate confirmation + “View run”, and jobs update canonical run lifecycle.
+
+**Independent Test**: Start each Phase 1 operation from its owning UI and confirm the request returns quickly, includes “View run”, and the run progresses queued → running → terminal outcome.
+
+### Tests for User Story 2
+
+- [x] T032 [P] [US2] Add Inventory “Sync now” start-surface tests in `tests/Feature/Inventory/InventorySyncStartSurfaceTest.php`
+- [x] T033 [P] [US2] Add Policies “Sync now” start-surface tests in `tests/Feature/PolicySyncStartSurfaceTest.php`
+- [x] T034 [P] [US2] Add Directory Groups “Sync groups” start-surface tests in `tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php`
+- [x] T035 [P] [US2] Add Drift “Generate drift” start-surface tests in `tests/Feature/Drift/DriftGenerationDispatchTest.php`
+- [x] T036 [P] [US2] Add Backup Set “Add policies” start-surface tests in `tests/Feature/BackupSets/AddPoliciesStartSurfaceTest.php`
+- [x] T037 [P] [US2] Add Backup Schedule “Run now/Retry” start-surface tests in `tests/Feature/BackupScheduling/RunNowRetryActionsTest.php`
+- [x] T067 [P] [US2] Add Backup Set “Remove policies” (row + bulk) start-surface tests in `tests/Feature/BackupSets/RemovePoliciesStartSurfaceTest.php` and `tests/Feature/Filament/BackupItemsBulkRemoveTest.php`
+- [x] T038 [P] [US2] Add “queued + terminal” notification tests (initiator only; safe content) in `tests/Feature/Notifications/OperationRunNotificationTest.php`
+
+### Implementation for User Story 2
+
+- [x] T039 [P] [US2] Refactor Inventory “Sync now” to ensure run + dispatch + “View run” in `app/Filament/Pages/InventoryLanding.php`
+- [x] T040 [P] [US2] Refactor Policies “Sync now” to ensure run + dispatch + “View run” in `app/Filament/Resources/PolicyResource/Pages/ListPolicies.php`
+- [x] T041 [P] [US2] Refactor Directory Groups “Sync groups” to ensure run + dispatch + “View run” in `app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php`
+- [x] T042 [P] [US2] Refactor Drift “Generate drift” (manual + auto-on-open) to ensure run + dispatch + “View run” in `app/Filament/Pages/DriftLanding.php`
+- [x] T043 [P] [US2] Refactor Backup Set “Add policies” Livewire action to ensure run + dispatch + “View run” in `app/Livewire/BackupSetPolicyPickerTable.php`
+- [x] T044 [P] [US2] Refactor Backup Schedule “Run now/Retry” actions to ensure run + dispatch + “View run” in `app/Filament/Resources/BackupScheduleResource.php`
+- [x] T068 [P] [US2] Refactor Backup Set “Remove policies” actions (row + bulk) to ensure run + dispatch + “View run” in `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php` and `app/Jobs/RemovePoliciesFromBackupSetJob.php`
+- [x] T045 [P] [US2] Instrument inventory sync job run lifecycle + summary/failures in `app/Jobs/RunInventorySyncJob.php`
+- [x] T046 [P] [US2] Instrument policy sync job run lifecycle + summary/failures in `app/Jobs/SyncPoliciesJob.php`
+- [x] T047 [P] [US2] Instrument groups sync job run lifecycle + summary/failures in `app/Jobs/EntraGroupSyncJob.php`
+- [x] T048 [P] [US2] Instrument drift generation job run lifecycle + summary/failures in `app/Jobs/GenerateDriftFindingsJob.php`
+- [x] T049 [P] [US2] Instrument backup set “add policies” job run lifecycle + summary/failures in `app/Jobs/AddPoliciesToBackupSetJob.php`
+- [x] T050 [P] [US2] Instrument backup schedule job run lifecycle + summary/failures in `app/Jobs/RunBackupScheduleJob.php`
+- [x] T051 [US2] Implement queued notification (after successful dispatch) in `app/Notifications/OperationRunQueued.php`
+- [x] T052 [US2] Implement terminal outcome notification (succeeded/partial/failed) in `app/Notifications/OperationRunCompleted.php`
+- [x] T053 [US2] Emit notifications from canonical lifecycle updates (initiator only) in `app/Services/OperationRunService.php`
+- [x] T054 [US2] Handle queue dispatch failures (fail fast; no misleading queued runs) in `app/Services/OperationRunService.php`
+
+---
+
+## Phase 5: User Story 3 - Duplicate Starts Reuse the Same Active Run (Priority: P3)
+
+**Goal**: Duplicate starts reuse the same active run (dedupe), enforced at DB level and validated by tests.
+
+**Independent Test**: Start the same operation twice with identical effective inputs while the first is queued/running and verify the system reuses the active run.
+
+### Tests for User Story 3
+
+- [x] T055 [US3] Add service-level dedupe + race-collision tests in `tests/Feature/OperationRunServiceTest.php`
+- [x] T056 [US3] Add end-to-end “reuse active run” test for at least one producer in `tests/Feature/PolicySyncStartSurfaceTest.php`
+
+### Implementation for User Story 3
+
+- [x] T057 [US3] Normalize identity inputs before hashing (stable JSON; ignore initiator) in `app/Services/OperationRunService.php`
+- [x] T058 [P] [US3] Ensure `inventory.sync` identity inputs follow FR-010 in `app/Filament/Pages/InventoryLanding.php`
+- [x] T059 [P] [US3] Ensure `policy.sync` identity inputs follow FR-010 in `app/Filament/Resources/PolicyResource/Pages/ListPolicies.php`
+- [x] T060 [P] [US3] Ensure `directory_groups.sync` identity inputs follow FR-010 in `app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php`
+- [x] T061 [P] [US3] Ensure `drift.generate` identity inputs follow FR-010 in `app/Filament/Pages/DriftLanding.php`
+- [x] T062 [P] [US3] Ensure `backup_set.add_policies` identity inputs follow FR-010 in `app/Livewire/BackupSetPolicyPickerTable.php`
+- [x] T063 [P] [US3] Ensure `backup_schedule.run_now`/`backup_schedule.retry` identity inputs follow FR-010 in `app/Filament/Resources/BackupScheduleResource.php`
+
+---
+
+## Phase 6: Polish & Cross-Cutting Concerns
+
+- [x] T064 [P] Run formatter on changed files via `./vendor/bin/pint --dirty`
+- [x] T065 Run targeted tests for this feature via `./vendor/bin/sail artisan test tests/Feature/MonitoringOperationsTest.php`
+- [x] T066 Run quickstart scenarios and update docs if needed in `specs/054-unify-runs-suitewide/quickstart.md`
+
+---
+
+## Dependencies & Execution Order
+
+### Phase Dependencies
+
+- Setup (Phase 1) → blocks Foundational
+- Foundational (Phase 2) → blocks US1/US2/US3
+- US1/US2/US3 can proceed after Phase 2 (parallel if staffed, or sequential P1 → P2 → P3)
+- Polish (Phase 6) depends on the desired user stories being complete
+
+### User Story Dependencies (Graph)
+
+```text
+Foundational
+ ├─ US1 (Monitoring UI)
+ ├─ US2 (Start surfaces + lifecycle + notifications)
+ └─ US3 (Dedupe + identity + race handling)
+```
+
+---
+
+## Parallel Execution Examples
+
+After Phase 2, these can run in parallel (different files/modules):
+
+### US1 (Monitoring UI)
+
+- `T023` (restore adapter tests) + `T024` (resource scaffold) + `T029` (restore adapter listener)
+
+### US2 (Start surfaces + lifecycle + notifications)
+
+- Tests: `T032`–`T038`
+- Producers/start surfaces: `T039`–`T044`
+- Workers/job instrumentation: `T045`–`T050`
+- Notification classes: `T051` + `T052`
+
+### US3 (Dedupe + identity + race handling)
+
+- Identity review per producer: `T058`–`T063`
+
+---
+
+## Implementation Strategy
+
+### MVP First (US1 Only)
+
+1. Complete Phase 1–2 (canonical run primitive)
+2. Complete US1 (Monitoring list/detail + tenant isolation)
+3. Validate Monitoring renders DB-only and is tenant-scoped
+
+### Incremental Delivery
+
+1. Add US2 producers/workers + notifications
+2. Add US3 dedupe + race validation
+3. Polish (formatting, targeted tests, quickstart validation)
-- [ ] **Notifications**: Implement Database Notifications for "Run Started" (with link) and "Run Completed" (with outcome).
-- [ ] **Frontend**: Ensure "View Run" link in Toast notifications correctly opens the Monitoring Detail view.
-- [ ] **Final Verify**: Run through the `requirements.md` checklist manually.
\ No newline at end of file
diff --git a/tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php b/tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php
index b6e1af6..2157638 100644
--- a/tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php
+++ b/tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php
@@ -7,6 +7,7 @@
use App\Services\BulkOperationService;
use App\Services\Intune\BackupService;
use App\Services\Intune\PolicySyncService;
+use App\Services\OperationRunService;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Cache;
@@ -37,6 +38,170 @@
'status' => BackupScheduleRun::STATUS_RUNNING,
]);
+ /** @var OperationRunService $operationRunService */
+ $operationRunService = app(OperationRunService::class);
+ $operationRun = $operationRunService->ensureRun(
+ tenant: $tenant,
+ type: 'backup_schedule.run_now',
+ inputs: ['backup_schedule_id' => (int) $schedule->id],
+ initiator: $user,
+ );
+
+ app()->bind(PolicySyncService::class, fn () => new class extends PolicySyncService
+ {
+ public function __construct() {}
+
+ public function syncPoliciesWithReport($tenant, ?array $supportedTypes = null): array
+ {
+ return ['synced' => [], 'failures' => []];
+ }
+ });
+
+ $backupSet = BackupSet::factory()->create([
+ 'tenant_id' => $tenant->id,
+ 'status' => 'completed',
+ 'item_count' => 0,
+ ]);
+
+ app()->bind(BackupService::class, fn () => new class($backupSet) extends BackupService
+ {
+ public function __construct(private readonly BackupSet $backupSet) {}
+
+ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null, ?string $actorName = null, ?string $name = null, bool $includeAssignments = false, bool $includeScopeTags = false, bool $includeFoundations = false): BackupSet
+ {
+ return $this->backupSet;
+ }
+ });
+
+ Cache::flush();
+
+ (new RunBackupScheduleJob($run->id, null, $operationRun))->handle(
+ app(PolicySyncService::class),
+ app(BackupService::class),
+ app(\App\Services\BackupScheduling\PolicyTypeResolver::class),
+ app(\App\Services\BackupScheduling\ScheduleTimeService::class),
+ app(\App\Services\Intune\AuditLogger::class),
+ app(\App\Services\BackupScheduling\RunErrorMapper::class),
+ app(BulkOperationService::class),
+ );
+
+ $run->refresh();
+ expect($run->status)->toBe(BackupScheduleRun::STATUS_SUCCESS);
+ expect($run->backup_set_id)->toBe($backupSet->id);
+
+ $operationRun->refresh();
+ expect($operationRun->status)->toBe('completed');
+ expect($operationRun->outcome)->toBe('succeeded');
+ expect($operationRun->summary_counts)->toMatchArray([
+ 'backup_schedule_id' => (int) $schedule->id,
+ 'backup_schedule_run_id' => (int) $run->id,
+ 'backup_set_id' => (int) $backupSet->id,
+ ]);
+});
+
+it('skips runs when all policy types are unknown', function () {
+ CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC'));
+
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+ $this->actingAs($user);
+
+ $schedule = BackupSchedule::query()->create([
+ 'tenant_id' => $tenant->id,
+ 'name' => 'Daily 10:00',
+ 'is_enabled' => true,
+ 'timezone' => 'UTC',
+ 'frequency' => 'daily',
+ 'time_of_day' => '10:00:00',
+ 'days_of_week' => null,
+ 'policy_types' => ['definitelyNotARealPolicyType'],
+ 'include_foundations' => true,
+ 'retention_keep_last' => 30,
+ 'next_run_at' => null,
+ ]);
+
+ $run = BackupScheduleRun::query()->create([
+ 'backup_schedule_id' => $schedule->id,
+ 'tenant_id' => $tenant->id,
+ 'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute(),
+ 'status' => BackupScheduleRun::STATUS_RUNNING,
+ ]);
+
+ Cache::flush();
+
+ /** @var OperationRunService $operationRunService */
+ $operationRunService = app(OperationRunService::class);
+ $operationRun = $operationRunService->ensureRun(
+ tenant: $tenant,
+ type: 'backup_schedule.run_now',
+ inputs: ['backup_schedule_id' => (int) $schedule->id],
+ initiator: $user,
+ );
+
+ (new RunBackupScheduleJob($run->id, null, $operationRun))->handle(
+ app(PolicySyncService::class),
+ app(BackupService::class),
+ app(\App\Services\BackupScheduling\PolicyTypeResolver::class),
+ app(\App\Services\BackupScheduling\ScheduleTimeService::class),
+ app(\App\Services\Intune\AuditLogger::class),
+ app(\App\Services\BackupScheduling\RunErrorMapper::class),
+ app(BulkOperationService::class),
+ );
+
+ $run->refresh();
+ expect($run->status)->toBe(BackupScheduleRun::STATUS_SKIPPED);
+ expect($run->error_code)->toBe('UNKNOWN_POLICY_TYPE');
+ expect($run->backup_set_id)->toBeNull();
+
+ $operationRun->refresh();
+ expect($operationRun->status)->toBe('completed');
+ expect($operationRun->outcome)->toBe('failed');
+ expect($operationRun->failure_summary)->toMatchArray([
+ ['code' => 'unknown_policy_type', 'message' => $run->error_message],
+ ]);
+});
+
+it('updates the operation run based on the backup schedule run id when not passed into the job', function () {
+ CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC'));
+
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+ $this->actingAs($user);
+
+ $schedule = BackupSchedule::query()->create([
+ 'tenant_id' => $tenant->id,
+ 'name' => 'Daily 10:00',
+ 'is_enabled' => true,
+ 'timezone' => 'UTC',
+ 'frequency' => 'daily',
+ 'time_of_day' => '10:00:00',
+ 'days_of_week' => null,
+ 'policy_types' => ['deviceConfiguration'],
+ 'include_foundations' => true,
+ 'retention_keep_last' => 30,
+ 'next_run_at' => null,
+ ]);
+
+ $run = BackupScheduleRun::query()->create([
+ 'backup_schedule_id' => $schedule->id,
+ 'tenant_id' => $tenant->id,
+ 'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute(),
+ 'status' => BackupScheduleRun::STATUS_RUNNING,
+ ]);
+
+ /** @var OperationRunService $operationRunService */
+ $operationRunService = app(OperationRunService::class);
+ $operationRun = $operationRunService->ensureRun(
+ tenant: $tenant,
+ type: 'backup_schedule.run_now',
+ inputs: ['backup_schedule_id' => (int) $schedule->id],
+ initiator: $user,
+ );
+
+ $operationRun->update([
+ 'context' => array_merge($operationRun->context ?? [], [
+ 'backup_schedule_run_id' => (int) $run->getKey(),
+ ]),
+ ]);
+
app()->bind(PolicySyncService::class, fn () => new class extends PolicySyncService
{
public function __construct() {}
@@ -75,52 +240,12 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null,
app(BulkOperationService::class),
);
- $run->refresh();
- expect($run->status)->toBe(BackupScheduleRun::STATUS_SUCCESS);
- expect($run->backup_set_id)->toBe($backupSet->id);
-});
-
-it('skips runs when all policy types are unknown', function () {
- CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC'));
-
- [$user, $tenant] = createUserWithTenant(role: 'owner');
- $this->actingAs($user);
-
- $schedule = BackupSchedule::query()->create([
- 'tenant_id' => $tenant->id,
- 'name' => 'Daily 10:00',
- 'is_enabled' => true,
- 'timezone' => 'UTC',
- 'frequency' => 'daily',
- 'time_of_day' => '10:00:00',
- 'days_of_week' => null,
- 'policy_types' => ['definitelyNotARealPolicyType'],
- 'include_foundations' => true,
- 'retention_keep_last' => 30,
- 'next_run_at' => null,
+ $operationRun->refresh();
+ expect($operationRun->status)->toBe('completed');
+ expect($operationRun->outcome)->toBe('succeeded');
+ expect($operationRun->summary_counts)->toMatchArray([
+ 'backup_schedule_id' => (int) $schedule->id,
+ 'backup_schedule_run_id' => (int) $run->id,
+ 'backup_set_id' => (int) $backupSet->id,
]);
-
- $run = BackupScheduleRun::query()->create([
- 'backup_schedule_id' => $schedule->id,
- 'tenant_id' => $tenant->id,
- 'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute(),
- 'status' => BackupScheduleRun::STATUS_RUNNING,
- ]);
-
- Cache::flush();
-
- (new RunBackupScheduleJob($run->id))->handle(
- app(PolicySyncService::class),
- app(BackupService::class),
- app(\App\Services\BackupScheduling\PolicyTypeResolver::class),
- app(\App\Services\BackupScheduling\ScheduleTimeService::class),
- app(\App\Services\Intune\AuditLogger::class),
- app(\App\Services\BackupScheduling\RunErrorMapper::class),
- app(BulkOperationService::class),
- );
-
- $run->refresh();
- expect($run->status)->toBe(BackupScheduleRun::STATUS_SKIPPED);
- expect($run->error_code)->toBe('UNKNOWN_POLICY_TYPE');
- expect($run->backup_set_id)->toBeNull();
});
diff --git a/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php b/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php
index 807fc5b..6e7b808 100644
--- a/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php
+++ b/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php
@@ -5,14 +5,29 @@
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\BulkOperationRun;
+use App\Models\OperationRun;
use App\Models\User;
+use App\Notifications\OperationRunQueued;
+use App\Services\Graph\GraphClientInterface;
+use App\Support\OperationRunLinks;
use Carbon\CarbonImmutable;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
+beforeEach(function () {
+ $this->mock(GraphClientInterface::class, function ($mock): void {
+ $mock->shouldReceive('listPolicies')->never();
+ $mock->shouldReceive('getPolicy')->never();
+ $mock->shouldReceive('getOrganization')->never();
+ $mock->shouldReceive('applyPolicy')->never();
+ $mock->shouldReceive('getServicePrincipalPermissions')->never();
+ $mock->shouldReceive('request')->never();
+ });
+});
+
test('operator can run now and it persists a database notification', function () {
- Queue::fake();
+ Queue::fake([RunBackupScheduleJob::class]);
[$user, $tenant] = createUserWithTenant(role: 'operator');
@@ -42,6 +57,17 @@
expect($run)->not->toBeNull();
expect($run->user_id)->toBe($user->id);
+ $operationRun = OperationRun::query()
+ ->where('tenant_id', $tenant->id)
+ ->where('type', 'backup_schedule.run_now')
+ ->first();
+
+ expect($operationRun)->not->toBeNull();
+ expect($operationRun->context)->toMatchArray([
+ 'backup_schedule_id' => (int) $schedule->id,
+ 'backup_schedule_run_id' => (int) $run->id,
+ ]);
+
expect(BulkOperationRun::query()
->where('tenant_id', $tenant->id)
->where('user_id', $user->id)
@@ -50,19 +76,29 @@
->count())
->toBe(1);
- Queue::assertPushed(RunBackupScheduleJob::class);
+ Queue::assertPushed(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($run, $operationRun): bool {
+ return $job->backupScheduleRunId === (int) $run->id
+ && $job->operationRun instanceof OperationRun
+ && $job->operationRun->is($operationRun);
+ });
$this->assertDatabaseCount('notifications', 1);
$this->assertDatabaseHas('notifications', [
'notifiable_id' => $user->id,
'notifiable_type' => User::class,
+ 'type' => OperationRunQueued::class,
'data->format' => 'filament',
- 'data->title' => 'Run dispatched',
+ 'data->title' => 'Operation queued',
]);
+
+ $notification = $user->notifications()->latest('id')->first();
+ expect($notification)->not->toBeNull();
+ expect($notification->data['actions'][0]['url'] ?? null)
+ ->toBe(OperationRunLinks::view($operationRun, $tenant));
});
test('operator can retry and it persists a database notification', function () {
- Queue::fake();
+ Queue::fake([RunBackupScheduleJob::class]);
[$user, $tenant] = createUserWithTenant(role: 'operator');
@@ -92,6 +128,17 @@
expect($run)->not->toBeNull();
expect($run->user_id)->toBe($user->id);
+ $operationRun = OperationRun::query()
+ ->where('tenant_id', $tenant->id)
+ ->where('type', 'backup_schedule.retry')
+ ->first();
+
+ expect($operationRun)->not->toBeNull();
+ expect($operationRun->context)->toMatchArray([
+ 'backup_schedule_id' => (int) $schedule->id,
+ 'backup_schedule_run_id' => (int) $run->id,
+ ]);
+
expect(BulkOperationRun::query()
->where('tenant_id', $tenant->id)
->where('user_id', $user->id)
@@ -100,13 +147,24 @@
->count())
->toBe(1);
- Queue::assertPushed(RunBackupScheduleJob::class);
+ Queue::assertPushed(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($run, $operationRun): bool {
+ return $job->backupScheduleRunId === (int) $run->id
+ && $job->operationRun instanceof OperationRun
+ && $job->operationRun->is($operationRun);
+ });
$this->assertDatabaseCount('notifications', 1);
$this->assertDatabaseHas('notifications', [
'notifiable_id' => $user->id,
+ 'notifiable_type' => User::class,
+ 'type' => OperationRunQueued::class,
'data->format' => 'filament',
- 'data->title' => 'Retry dispatched',
+ 'data->title' => 'Operation queued',
]);
+
+ $notification = $user->notifications()->latest('id')->first();
+ expect($notification)->not->toBeNull();
+ expect($notification->data['actions'][0]['url'] ?? null)
+ ->toBe(OperationRunLinks::view($operationRun, $tenant));
});
test('readonly cannot dispatch run now or retry', function () {
@@ -144,10 +202,16 @@
expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count())
->toBe(0);
+
+ expect(OperationRun::query()
+ ->where('tenant_id', $tenant->id)
+ ->whereIn('type', ['backup_schedule.run_now', 'backup_schedule.retry'])
+ ->count())
+ ->toBe(0);
});
test('operator can bulk run now and it persists a database notification', function () {
- Queue::fake();
+ Queue::fake([RunBackupScheduleJob::class]);
[$user, $tenant] = createUserWithTenant(role: 'operator');
@@ -189,6 +253,12 @@
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleA->id)->value('user_id'))->toBe($user->id);
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleB->id)->value('user_id'))->toBe($user->id);
+ expect(OperationRun::query()
+ ->where('tenant_id', $tenant->id)
+ ->where('type', 'backup_schedule.run_now')
+ ->count())
+ ->toBe(2);
+
expect(BulkOperationRun::query()
->where('tenant_id', $tenant->id)
->where('user_id', $user->id)
@@ -204,10 +274,15 @@
'data->format' => 'filament',
'data->title' => 'Runs dispatched',
]);
+
+ $notification = $user->notifications()->latest('id')->first();
+ expect($notification)->not->toBeNull();
+ expect($notification->data['actions'][0]['url'] ?? null)
+ ->toBe(OperationRunLinks::index($tenant));
});
test('operator can bulk retry and it persists a database notification', function () {
- Queue::fake();
+ Queue::fake([RunBackupScheduleJob::class]);
[$user, $tenant] = createUserWithTenant(role: 'operator');
@@ -249,6 +324,12 @@
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleA->id)->value('user_id'))->toBe($user->id);
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleB->id)->value('user_id'))->toBe($user->id);
+ expect(OperationRun::query()
+ ->where('tenant_id', $tenant->id)
+ ->where('type', 'backup_schedule.retry')
+ ->count())
+ ->toBe(2);
+
expect(BulkOperationRun::query()
->where('tenant_id', $tenant->id)
->where('user_id', $user->id)
@@ -264,10 +345,15 @@
'data->format' => 'filament',
'data->title' => 'Retries dispatched',
]);
+
+ $notification = $user->notifications()->latest('id')->first();
+ expect($notification)->not->toBeNull();
+ expect($notification->data['actions'][0]['url'] ?? null)
+ ->toBe(OperationRunLinks::index($tenant));
});
test('operator can bulk retry even if a run already exists for this minute', function () {
- Queue::fake();
+ Queue::fake([RunBackupScheduleJob::class]);
[$user, $tenant] = createUserWithTenant(role: 'operator');
diff --git a/tests/Feature/BackupSets/AddPoliciesStartSurfaceTest.php b/tests/Feature/BackupSets/AddPoliciesStartSurfaceTest.php
new file mode 100644
index 0000000..f6c82cd
--- /dev/null
+++ b/tests/Feature/BackupSets/AddPoliciesStartSurfaceTest.php
@@ -0,0 +1,70 @@
+mock(GraphClientInterface::class, function ($mock): void {
+ $mock->shouldReceive('listPolicies')->never();
+ $mock->shouldReceive('getPolicy')->never();
+ $mock->shouldReceive('getOrganization')->never();
+ $mock->shouldReceive('applyPolicy')->never();
+ $mock->shouldReceive('getServicePrincipalPermissions')->never();
+ $mock->shouldReceive('request')->never();
+ });
+
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+ $this->actingAs($user);
+
+ $tenant->makeCurrent();
+ Filament::setTenant($tenant, true);
+
+ $backupSet = BackupSet::factory()->create([
+ 'tenant_id' => $tenant->id,
+ 'name' => 'Test backup',
+ ]);
+
+ $policies = Policy::factory()->count(2)->create([
+ 'tenant_id' => $tenant->id,
+ 'ignored_at' => null,
+ 'last_synced_at' => now(),
+ ]);
+
+ Livewire::actingAs($user)
+ ->test(BackupSetPolicyPickerTable::class, [
+ 'backupSetId' => $backupSet->id,
+ ])
+ ->callTableBulkAction('add_selected_to_backup_set', $policies)
+ ->assertHasNoTableBulkActionErrors();
+
+ $opRun = OperationRun::query()
+ ->where('tenant_id', $tenant->getKey())
+ ->where('type', 'backup_set.add_policies')
+ ->latest('id')
+ ->first();
+
+ expect($opRun)->not->toBeNull();
+ expect($opRun?->status)->toBe('queued');
+ expect($opRun?->outcome)->toBe('pending');
+ expect($opRun?->context['backup_set_id'] ?? null)->toBe((int) $backupSet->getKey());
+
+ $notifications = session('filament.notifications', []);
+ expect($notifications)->not->toBeEmpty();
+ expect(collect($notifications)->last()['actions'][0]['url'] ?? null)
+ ->toBe(OperationRunLinks::view($opRun, $tenant));
+
+ Queue::assertPushed(AddPoliciesToBackupSetJob::class, function (AddPoliciesToBackupSetJob $job) use ($opRun): bool {
+ return $job->operationRun instanceof OperationRun
+ && (int) $job->operationRun->getKey() === (int) $opRun?->getKey();
+ });
+});
diff --git a/tests/Feature/BackupSets/RemovePoliciesJobNotificationTest.php b/tests/Feature/BackupSets/RemovePoliciesJobNotificationTest.php
new file mode 100644
index 0000000..7ecdb52
--- /dev/null
+++ b/tests/Feature/BackupSets/RemovePoliciesJobNotificationTest.php
@@ -0,0 +1,65 @@
+create([
+ 'tenant_id' => $tenant->getKey(),
+ 'name' => 'Test backup',
+ 'item_count' => 0,
+ ]);
+
+ $item = BackupItem::factory()->create([
+ 'tenant_id' => $tenant->getKey(),
+ 'backup_set_id' => $backupSet->getKey(),
+ ]);
+
+ $backupSet->update(['item_count' => $backupSet->items()->count()]);
+
+ $opRun = OperationRun::create([
+ 'tenant_id' => $tenant->getKey(),
+ 'user_id' => $user->getKey(),
+ 'initiator_name' => $user->name,
+ 'type' => 'backup_set.remove_policies',
+ 'status' => 'queued',
+ 'outcome' => 'pending',
+ 'run_identity_hash' => 'remove-hash-1',
+ 'context' => [
+ 'backup_set_id' => (int) $backupSet->getKey(),
+ 'backup_item_ids' => [(int) $item->getKey()],
+ ],
+ ]);
+
+ $this->mock(AuditLogger::class, function ($mock): void {
+ $mock->shouldReceive('log')->zeroOrMoreTimes();
+ });
+
+ $job = new RemovePoliciesFromBackupSetJob(
+ backupSetId: (int) $backupSet->getKey(),
+ backupItemIds: [(int) $item->getKey()],
+ initiatorUserId: (int) $user->getKey(),
+ operationRun: $opRun,
+ );
+
+ $job->handle(app(AuditLogger::class), app(BulkOperationService::class));
+
+ $this->assertDatabaseHas('notifications', [
+ 'notifiable_id' => $user->getKey(),
+ 'notifiable_type' => $user->getMorphClass(),
+ 'type' => DatabaseNotification::class,
+ ]);
+
+ $notification = $user->notifications()->latest('id')->first();
+ expect($notification)->not->toBeNull();
+ expect($notification->data['actions'][0]['url'] ?? null)
+ ->toBe(OperationRunLinks::view($opRun, $tenant));
+});
diff --git a/tests/Feature/BackupSets/RemovePoliciesStartSurfaceTest.php b/tests/Feature/BackupSets/RemovePoliciesStartSurfaceTest.php
new file mode 100644
index 0000000..585b93d
--- /dev/null
+++ b/tests/Feature/BackupSets/RemovePoliciesStartSurfaceTest.php
@@ -0,0 +1,60 @@
+mock(GraphClientInterface::class, function ($mock): void {
+ $mock->shouldReceive('listPolicies')->never();
+ $mock->shouldReceive('getPolicy')->never();
+ $mock->shouldReceive('getOrganization')->never();
+ $mock->shouldReceive('applyPolicy')->never();
+ $mock->shouldReceive('getServicePrincipalPermissions')->never();
+ $mock->shouldReceive('request')->never();
+ });
+
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+ $this->actingAs($user);
+
+ $tenant->makeCurrent();
+ Filament::setTenant($tenant, true);
+
+ $backupSet = BackupSet::factory()->create([
+ 'tenant_id' => $tenant->id,
+ 'name' => 'Test backup',
+ ]);
+
+ $backupItem = BackupItem::factory()->for($backupSet)->for($tenant)->create();
+
+ Livewire::test(BackupItemsRelationManager::class, [
+ 'ownerRecord' => $backupSet,
+ 'pageClass' => EditBackupSet::class,
+ ])
+ ->callTableAction('remove', $backupItem);
+
+ Queue::assertPushed(RemovePoliciesFromBackupSetJob::class, function (RemovePoliciesFromBackupSetJob $job) use ($backupSet, $backupItem, $user) {
+ return $job->backupSetId === (int) $backupSet->getKey()
+ && $job->backupItemIds === [(int) $backupItem->getKey()]
+ && $job->initiatorUserId === (int) $user->getKey();
+ });
+
+ $run = OperationRun::query()
+ ->where('tenant_id', $tenant->getKey())
+ ->where('type', 'backup_set.remove_policies')
+ ->latest('id')
+ ->first();
+
+ expect($run)->not->toBeNull();
+ expect($run->status)->toBe('queued');
+ expect($run->context['backup_set_id'] ?? null)->toBe((int) $backupSet->getKey());
+});
diff --git a/tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php b/tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php
new file mode 100644
index 0000000..105ab0c
--- /dev/null
+++ b/tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php
@@ -0,0 +1,88 @@
+create();
+
+ $schedule = BackupSchedule::create([
+ 'tenant_id' => $tenant->id,
+ 'name' => 'Daily',
+ 'is_enabled' => true,
+ 'timezone' => 'UTC',
+ 'frequency' => 'daily',
+ 'time_of_day' => '10:00:00',
+ 'days_of_week' => null,
+ 'policy_types' => ['deviceConfiguration'],
+ 'include_foundations' => true,
+ 'retention_keep_last' => 30,
+ 'last_run_at' => null,
+ 'last_run_status' => null,
+ 'next_run_at' => null,
+ ]);
+
+ $startedAt = CarbonImmutable::parse('2026-01-01 00:00:00', 'UTC');
+ $finishedAt = CarbonImmutable::parse('2026-01-01 00:00:05', 'UTC');
+
+ $scheduleRun = BackupScheduleRun::create([
+ 'backup_schedule_id' => $schedule->id,
+ 'tenant_id' => $tenant->id,
+ 'scheduled_for' => CarbonImmutable::parse('2026-01-01 00:00:00', 'UTC'),
+ 'started_at' => $startedAt,
+ 'finished_at' => $finishedAt,
+ 'status' => BackupScheduleRun::STATUS_SUCCESS,
+ 'summary' => [
+ 'policies_total' => 5,
+ 'policies_backed_up' => 18,
+ 'sync_failures' => [],
+ ],
+ 'error_code' => null,
+ 'error_message' => null,
+ 'backup_set_id' => null,
+ ]);
+
+ $operationRun = OperationRun::create([
+ 'tenant_id' => $tenant->id,
+ 'user_id' => null,
+ 'initiator_name' => 'System',
+ 'type' => 'backup_schedule.run_now',
+ 'status' => 'queued',
+ 'outcome' => 'pending',
+ 'run_identity_hash' => hash('sha256', 'backup_schedule.run_now|'.$scheduleRun->id),
+ 'summary_counts' => [],
+ 'failure_summary' => [],
+ 'context' => [
+ 'backup_schedule_id' => (int) $schedule->id,
+ 'backup_schedule_run_id' => (int) $scheduleRun->id,
+ ],
+ ]);
+
+ $this->artisan('tenantpilot:operation-runs:reconcile-backup-schedules', [
+ '--tenant' => [$tenant->id],
+ '--older-than' => 0,
+ ])->assertSuccessful();
+
+ $operationRun->refresh();
+
+ expect($operationRun->status)->toBe('completed');
+ expect($operationRun->outcome)->toBe('succeeded');
+ expect($operationRun->failure_summary)->toBe([]);
+
+ expect($operationRun->started_at?->format('Y-m-d H:i:s'))->toBe($startedAt->format('Y-m-d H:i:s'));
+ expect($operationRun->completed_at?->format('Y-m-d H:i:s'))->toBe($finishedAt->format('Y-m-d H:i:s'));
+
+ expect($operationRun->summary_counts)->toMatchArray([
+ 'backup_schedule_id' => (int) $schedule->id,
+ 'backup_schedule_run_id' => (int) $scheduleRun->id,
+ 'policies_total' => 5,
+ 'policies_backed_up' => 18,
+ 'sync_failures' => 0,
+ ]);
+});
diff --git a/tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php b/tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php
new file mode 100644
index 0000000..d93ea1b
--- /dev/null
+++ b/tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php
@@ -0,0 +1,72 @@
+mock(GraphClientInterface::class, function ($mock): void {
+ $mock->shouldReceive('listPolicies')->never();
+ $mock->shouldReceive('getPolicy')->never();
+ $mock->shouldReceive('getOrganization')->never();
+ $mock->shouldReceive('applyPolicy')->never();
+ $mock->shouldReceive('getServicePrincipalPermissions')->never();
+ $mock->shouldReceive('request')->never();
+ });
+
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+ $this->actingAs($user);
+
+ $tenant->makeCurrent();
+ Filament::setTenant($tenant, true);
+
+ Livewire::test(ListEntraGroups::class)
+ ->callAction('sync_groups');
+
+ $run = EntraGroupSyncRun::query()
+ ->where('tenant_id', $tenant->getKey())
+ ->latest('id')
+ ->first();
+
+ expect($run)->not->toBeNull();
+ expect($run?->status)->toBe(EntraGroupSyncRun::STATUS_PENDING);
+ expect($run?->selection_key)->toBe('groups-v1:all');
+
+ $opRun = OperationRun::query()
+ ->where('tenant_id', $tenant->getKey())
+ ->where('type', 'directory_groups.sync')
+ ->latest('id')
+ ->first();
+
+ expect($opRun)->not->toBeNull();
+ expect($opRun?->status)->toBe('queued');
+
+ Queue::assertPushed(EntraGroupSyncJob::class, function (EntraGroupSyncJob $job) use ($tenant, $run, $opRun): bool {
+ return $job->tenantId === (int) $tenant->getKey()
+ && $job->runId === (int) $run?->getKey()
+ && $job->operationRun instanceof OperationRun
+ && (int) $job->operationRun->getKey() === (int) $opRun?->getKey();
+ });
+});
+
+it('hides group sync start action for readonly users', function () {
+ Queue::fake();
+
+ [$user, $tenant] = createUserWithTenant(role: 'readonly');
+ $this->actingAs($user);
+
+ $tenant->makeCurrent();
+ Filament::setTenant($tenant, true);
+
+ Livewire::test(ListEntraGroups::class)
+ ->assertActionHidden('sync_groups');
+
+ Queue::assertNothingPushed();
+});
diff --git a/tests/Feature/Drift/DriftGenerationDispatchTest.php b/tests/Feature/Drift/DriftGenerationDispatchTest.php
index 5807cc4..5e3f748 100644
--- a/tests/Feature/Drift/DriftGenerationDispatchTest.php
+++ b/tests/Feature/Drift/DriftGenerationDispatchTest.php
@@ -1,16 +1,28 @@
mock(GraphClientInterface::class, function ($mock): void {
+ $mock->shouldReceive('listPolicies')->never();
+ $mock->shouldReceive('getPolicy')->never();
+ $mock->shouldReceive('getOrganization')->never();
+ $mock->shouldReceive('applyPolicy')->never();
+ $mock->shouldReceive('getServicePrincipalPermissions')->never();
+ $mock->shouldReceive('request')->never();
+ });
+});
+
test('opening Drift dispatches generation when findings are missing', function () {
Queue::fake();
@@ -61,24 +73,30 @@
'current_run_id' => (int) $current->getKey(),
]);
- $this->assertDatabaseHas('notifications', [
- 'notifiable_id' => $user->getKey(),
- 'notifiable_type' => $user->getMorphClass(),
- 'type' => RunStatusChangedNotification::class,
- ]);
+ $opRun = OperationRun::query()
+ ->where('tenant_id', $tenant->getKey())
+ ->where('type', 'drift.generate')
+ ->latest('id')
+ ->first();
- $notification = $user->notifications()->latest('id')->first();
- expect($notification)->not->toBeNull();
- expect($notification->data['actions'][0]['url'] ?? null)
- ->toBe(BulkOperationRunResource::getUrl('view', ['record' => $bulkRun->getKey()], tenant: $tenant));
+ expect($opRun)->not->toBeNull();
+ expect($opRun?->status)->toBe('queued');
- Queue::assertPushed(GenerateDriftFindingsJob::class, function (GenerateDriftFindingsJob $job) use ($tenant, $user, $baseline, $current, $scopeKey, $bulkRun): bool {
+ $notifications = session('filament.notifications', []);
+
+ expect($notifications)->not->toBeEmpty();
+ expect(collect($notifications)->last()['actions'][0]['url'] ?? null)
+ ->toBe(OperationRunLinks::view($opRun, $tenant));
+
+ Queue::assertPushed(GenerateDriftFindingsJob::class, function (GenerateDriftFindingsJob $job) use ($tenant, $user, $baseline, $current, $scopeKey, $bulkRun, $opRun): bool {
return $job->tenantId === (int) $tenant->getKey()
&& $job->userId === (int) $user->getKey()
&& $job->baselineRunId === (int) $baseline->getKey()
&& $job->currentRunId === (int) $current->getKey()
&& $job->scopeKey === $scopeKey
- && $job->bulkOperationRunId === (int) $bulkRun->getKey();
+ && $job->bulkOperationRunId === (int) $bulkRun->getKey()
+ && $job->operationRun instanceof OperationRun
+ && (int) $job->operationRun->getKey() === (int) $opRun?->getKey();
});
});
@@ -123,6 +141,11 @@
->where('tenant_id', $tenant->getKey())
->where('idempotency_key', $idempotencyKey)
->count())->toBe(1);
+
+ expect(OperationRun::query()
+ ->where('tenant_id', $tenant->getKey())
+ ->where('type', 'drift.generate')
+ ->count())->toBe(1);
});
test('opening Drift does not dispatch generation when fewer than two successful runs exist', function () {
@@ -144,6 +167,7 @@
Queue::assertNothingPushed();
expect(BulkOperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
+ expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
});
test('opening Drift does not dispatch generation for readonly users', function () {
@@ -171,4 +195,5 @@
Queue::assertNothingPushed();
expect(BulkOperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
+ expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
});
diff --git a/tests/Feature/Drift/GenerateDriftFindingsJobNotificationTest.php b/tests/Feature/Drift/GenerateDriftFindingsJobNotificationTest.php
index 0100470..4cdf501 100644
--- a/tests/Feature/Drift/GenerateDriftFindingsJobNotificationTest.php
+++ b/tests/Feature/Drift/GenerateDriftFindingsJobNotificationTest.php
@@ -1,12 +1,13 @@
[],
]);
+ $opRun = OperationRun::create([
+ 'tenant_id' => $tenant->getKey(),
+ 'user_id' => $user->getKey(),
+ 'initiator_name' => $user->name,
+ 'type' => 'drift.generate',
+ 'status' => 'queued',
+ 'outcome' => 'pending',
+ 'run_identity_hash' => 'drift-hash-1',
+ 'context' => [
+ 'scope_key' => $scopeKey,
+ 'baseline_run_id' => (int) $baseline->getKey(),
+ 'current_run_id' => (int) $current->getKey(),
+ ],
+ ]);
+
$this->mock(DriftFindingGenerator::class, function (MockInterface $mock) {
$mock->shouldReceive('generate')->once()->andReturn(0);
});
@@ -51,6 +67,7 @@
currentRunId: (int) $current->getKey(),
scopeKey: $scopeKey,
bulkOperationRunId: (int) $run->getKey(),
+ operationRun: $opRun,
);
$job->handle(app(DriftFindingGenerator::class), app(BulkOperationService::class));
@@ -60,13 +77,13 @@
$this->assertDatabaseHas('notifications', [
'notifiable_id' => $user->getKey(),
'notifiable_type' => $user->getMorphClass(),
- 'type' => RunStatusChangedNotification::class,
+ 'type' => DatabaseNotification::class,
]);
$notification = $user->notifications()->latest('id')->first();
expect($notification)->not->toBeNull();
expect($notification->data['actions'][0]['url'] ?? null)
- ->toBe(BulkOperationRunResource::getUrl('view', ['record' => $run->getKey()], tenant: $tenant));
+ ->toBe(OperationRunLinks::view($opRun, $tenant));
});
test('drift generation job sends failure notification with view link', function () {
@@ -100,6 +117,21 @@
'failures' => [],
]);
+ $opRun = OperationRun::create([
+ 'tenant_id' => $tenant->getKey(),
+ 'user_id' => $user->getKey(),
+ 'initiator_name' => $user->name,
+ 'type' => 'drift.generate',
+ 'status' => 'queued',
+ 'outcome' => 'pending',
+ 'run_identity_hash' => 'drift-hash-2',
+ 'context' => [
+ 'scope_key' => $scopeKey,
+ 'baseline_run_id' => (int) $baseline->getKey(),
+ 'current_run_id' => (int) $current->getKey(),
+ ],
+ ]);
+
$this->mock(DriftFindingGenerator::class, function (MockInterface $mock) {
$mock->shouldReceive('generate')->once()->andThrow(new RuntimeException('boom'));
});
@@ -111,6 +143,7 @@
currentRunId: (int) $current->getKey(),
scopeKey: $scopeKey,
bulkOperationRunId: (int) $run->getKey(),
+ operationRun: $opRun,
);
try {
@@ -133,11 +166,11 @@
$this->assertDatabaseHas('notifications', [
'notifiable_id' => $user->getKey(),
'notifiable_type' => $user->getMorphClass(),
- 'type' => RunStatusChangedNotification::class,
+ 'type' => DatabaseNotification::class,
]);
$notification = $user->notifications()->latest('id')->first();
expect($notification)->not->toBeNull();
expect($notification->data['actions'][0]['url'] ?? null)
- ->toBe(BulkOperationRunResource::getUrl('view', ['record' => $run->getKey()], tenant: $tenant));
+ ->toBe(OperationRunLinks::view($opRun, $tenant));
});
diff --git a/tests/Feature/Filament/BackupItemsBulkRemoveTest.php b/tests/Feature/Filament/BackupItemsBulkRemoveTest.php
index a1a0ae6..a2cecee 100644
--- a/tests/Feature/Filament/BackupItemsBulkRemoveTest.php
+++ b/tests/Feature/Filament/BackupItemsBulkRemoveTest.php
@@ -1,21 +1,25 @@
create();
$tenant->makeCurrent();
- $user = User::factory()->create();
+ [$user] = createUserWithTenant(tenant: $tenant, role: 'owner');
$backupSet = BackupSet::factory()->create([
'tenant_id' => $tenant->id,
@@ -64,11 +68,21 @@
->callTableBulkAction('bulk_remove', collect([$itemA, $itemB]))
->assertHasNoTableBulkActionErrors();
+ Queue::assertPushed(RemovePoliciesFromBackupSetJob::class);
+
+ $run = OperationRun::query()
+ ->where('tenant_id', $tenant->getKey())
+ ->where('type', 'backup_set.remove_policies')
+ ->latest('id')
+ ->first();
+
+ expect($run)->not->toBeNull();
+ expect($run?->status)->toBe('queued');
+ expect($run?->outcome)->toBe('pending');
+ expect($run?->context['backup_set_id'] ?? null)->toBe((int) $backupSet->getKey());
+
+ // Enqueue-only: deletion happens in the job.
$backupSet->refresh();
-
- expect($backupSet->items()->count())->toBe(0);
- expect($backupSet->item_count)->toBe(0);
-
- $this->assertSoftDeleted('backup_items', ['id' => $itemA->id]);
- $this->assertSoftDeleted('backup_items', ['id' => $itemB->id]);
+ expect($backupSet->items()->count())->toBe(2);
+ expect($backupSet->item_count)->toBe(2);
});
diff --git a/tests/Feature/Inventory/InventorySyncStartSurfaceTest.php b/tests/Feature/Inventory/InventorySyncStartSurfaceTest.php
new file mode 100644
index 0000000..681266a
--- /dev/null
+++ b/tests/Feature/Inventory/InventorySyncStartSurfaceTest.php
@@ -0,0 +1,58 @@
+mock(GraphClientInterface::class, function ($mock): void {
+ $mock->shouldReceive('listPolicies')->never();
+ $mock->shouldReceive('getPolicy')->never();
+ $mock->shouldReceive('getOrganization')->never();
+ $mock->shouldReceive('applyPolicy')->never();
+ $mock->shouldReceive('getServicePrincipalPermissions')->never();
+ $mock->shouldReceive('request')->never();
+ });
+
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+ $this->actingAs($user);
+
+ $tenant->makeCurrent();
+ Filament::setTenant($tenant, true);
+
+ $sync = app(InventorySyncService::class);
+ $policyTypes = $sync->defaultSelectionPayload()['policy_types'] ?? [];
+
+ Livewire::test(InventoryLanding::class)
+ ->callAction('run_inventory_sync', data: ['policy_types' => $policyTypes]);
+
+ $opRun = OperationRun::query()
+ ->where('tenant_id', $tenant->getKey())
+ ->where('type', 'inventory.sync')
+ ->latest('id')
+ ->first();
+
+ expect($opRun)->not->toBeNull();
+ expect($opRun?->status)->toBe('queued');
+ expect($opRun?->outcome)->toBe('pending');
+
+ $notifications = session('filament.notifications', []);
+ expect($notifications)->not->toBeEmpty();
+ expect(collect($notifications)->last()['actions'][0]['url'] ?? null)
+ ->toBe(OperationRunLinks::view($opRun, $tenant));
+
+ Queue::assertPushed(RunInventorySyncJob::class, function (RunInventorySyncJob $job) use ($tenant, $user, $opRun): bool {
+ return $job->tenantId === (int) $tenant->getKey()
+ && $job->userId === (int) $user->getKey()
+ && $job->operationRun instanceof OperationRun
+ && (int) $job->operationRun->getKey() === (int) $opRun?->getKey();
+ });
+});
diff --git a/tests/Feature/Inventory/RunInventorySyncJobTest.php b/tests/Feature/Inventory/RunInventorySyncJobTest.php
index d560269..b0ed7da 100644
--- a/tests/Feature/Inventory/RunInventorySyncJobTest.php
+++ b/tests/Feature/Inventory/RunInventorySyncJobTest.php
@@ -23,7 +23,7 @@
$selectionPayload = $sync->defaultSelectionPayload();
$computed = $sync->normalizeAndHashSelection($selectionPayload);
$policyTypes = $computed['selection']['policy_types'];
- $run = $sync->createPendingRunForUser($tenant, $user, $selectionPayload);
+ $run = $sync->createPendingRunForUser($tenant, $user, $computed['selection']);
$bulkRun = app(BulkOperationService::class)->createRun(
tenant: $tenant,
@@ -52,8 +52,10 @@
expect($run->finished_at)->not->toBeNull();
expect($bulkRun->status)->toBe('completed');
- expect($bulkRun->processed_items)->toBe(count($policyTypes));
- expect($bulkRun->succeeded)->toBe(count($policyTypes));
+ expect($bulkRun->failed)->toBe(0);
+ expect($bulkRun->skipped)->toBe(0);
+ expect($bulkRun->processed_items)->toBeGreaterThan(0);
+ expect($bulkRun->processed_items)->toBe($bulkRun->succeeded);
});
it('maps skipped inventory sync runs to bulk progress as skipped with reason', function () {
@@ -66,6 +68,8 @@
$computed = $sync->normalizeAndHashSelection($selectionPayload);
$policyTypes = $computed['selection']['policy_types'];
+ $run->update(['selection_payload' => $computed['selection']]);
+
$bulkRun = app(BulkOperationService::class)->createRun(
tenant: $tenant,
user: $user,
diff --git a/tests/Feature/MonitoringOperationsTest.php b/tests/Feature/MonitoringOperationsTest.php
new file mode 100644
index 0000000..4571262
--- /dev/null
+++ b/tests/Feature/MonitoringOperationsTest.php
@@ -0,0 +1,140 @@
+create();
+ $user = User::factory()->create();
+ $tenant->users()->attach($user);
+
+ $run = OperationRun::create([
+ 'tenant_id' => $tenant->id,
+ 'type' => 'test.run',
+ 'status' => 'queued',
+ 'outcome' => 'pending',
+ 'initiator_name' => 'System',
+ 'run_identity_hash' => 'hash123',
+ ]);
+
+ $this->actingAs($user)
+ ->get(OperationRunResource::getUrl('index', tenant: $tenant))
+ ->assertSuccessful()
+ ->assertSee('test.run');
+});
+
+it('renders monitoring pages DB-only (never calls Graph)', function () {
+ $tenant = Tenant::factory()->create();
+ $user = User::factory()->create();
+ $tenant->users()->attach($user);
+
+ $run = OperationRun::create([
+ 'tenant_id' => $tenant->id,
+ 'type' => 'test.run',
+ 'status' => 'queued',
+ 'outcome' => 'pending',
+ 'initiator_name' => 'System',
+ 'run_identity_hash' => 'hash123',
+ ]);
+
+ $this->mock(GraphClientInterface::class, function ($mock): void {
+ $mock->shouldReceive('listPolicies')->never();
+ $mock->shouldReceive('getPolicy')->never();
+ $mock->shouldReceive('getOrganization')->never();
+ $mock->shouldReceive('applyPolicy')->never();
+ $mock->shouldReceive('getServicePrincipalPermissions')->never();
+ $mock->shouldReceive('request')->never();
+ });
+
+ $this->actingAs($user)
+ ->get(OperationRunResource::getUrl('index', tenant: $tenant))
+ ->assertSuccessful();
+
+ $this->actingAs($user)
+ ->get(OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant))
+ ->assertSuccessful();
+});
+
+it('shows runs only for current tenant', function () {
+ $tenantA = Tenant::factory()->create();
+ $tenantB = Tenant::factory()->create();
+ $user = User::factory()->create();
+ $tenantA->users()->attach($user);
+
+ // We must simulate being in tenant context
+ $this->actingAs($user);
+ // Filament::setTenant($tenantA); // This is usually handled by middleware on routes, but in Livewire test we might need manual set or route visit.
+
+ // Easier approach: visit the page for tenantA
+
+ OperationRun::create([
+ 'tenant_id' => $tenantA->id,
+ 'type' => 'tenantA.run',
+ 'status' => 'queued',
+ 'outcome' => 'pending',
+ 'initiator_name' => 'System',
+ 'run_identity_hash' => 'hashA',
+ ]);
+
+ OperationRun::create([
+ 'tenant_id' => $tenantB->id,
+ 'type' => 'tenantB.run',
+ 'status' => 'queued',
+ 'outcome' => 'pending',
+ 'initiator_name' => 'System',
+ 'run_identity_hash' => 'hashB',
+ ]);
+
+ // Livewire::test needs to know the tenant if the component relies on it.
+ // However, the component relies on `Filament::getTenant()`.
+ // The cleanest way is to just GET the page URL, which runs middleware.
+
+ $this->get(OperationRunResource::getUrl('index', tenant: $tenantA))
+ ->assertSee('tenantA.run')
+ ->assertDontSee('tenantB.run');
+});
+
+it('allows readonly users to view operations list and detail', function () {
+ $tenant = Tenant::factory()->create();
+ $user = User::factory()->create();
+ $tenant->users()->attach($user, ['role' => 'readonly']);
+
+ $run = OperationRun::create([
+ 'tenant_id' => $tenant->id,
+ 'type' => 'test.run',
+ 'status' => 'queued',
+ 'outcome' => 'pending',
+ 'initiator_name' => 'System',
+ 'run_identity_hash' => 'hash123',
+ ]);
+
+ $this->actingAs($user)
+ ->get(OperationRunResource::getUrl('index', tenant: $tenant))
+ ->assertSuccessful()
+ ->assertSee('test.run');
+
+ $this->actingAs($user)
+ ->get(OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant))
+ ->assertSuccessful()
+ ->assertSee('test.run');
+});
+
+it('denies access to unauthorized users', function () {
+ $tenant = Tenant::factory()->create();
+ $user = User::factory()->create();
+ // Not attached to tenant
+
+ // In a multitenant app, if you try to access a tenant route you are not part of,
+ // Filament typically returns 404 (Not Found) if it can't find the tenant-user relationship, or 403.
+ // The previous fail said "Received 404". This confirms Filament couldn't find the tenant for this user scope or just hides it.
+ // We should accept 404 or 403.
+
+ $response = $this->actingAs($user)
+ ->get(OperationRunResource::getUrl('index', tenant: $tenant));
+
+ // Allow either 403 or 404 as "Denied"
+ $this->assertTrue(in_array($response->status(), [403, 404]));
+});
diff --git a/tests/Feature/Notifications/OperationRunNotificationTest.php b/tests/Feature/Notifications/OperationRunNotificationTest.php
new file mode 100644
index 0000000..5371c39
--- /dev/null
+++ b/tests/Feature/Notifications/OperationRunNotificationTest.php
@@ -0,0 +1,131 @@
+actingAs($user);
+
+ $tenant->makeCurrent();
+ Filament::setTenant($tenant, true);
+
+ $service = app(OperationRunService::class);
+ $run = $service->ensureRun(
+ tenant: $tenant,
+ type: 'policy.sync',
+ inputs: ['scope' => 'all'],
+ initiator: $user,
+ );
+
+ $service->dispatchOrFail($run, function (): void {
+ // no-op (dispatch succeeded)
+ });
+
+ $this->assertDatabaseHas('notifications', [
+ 'notifiable_id' => $user->getKey(),
+ 'notifiable_type' => $user->getMorphClass(),
+ 'type' => OperationRunQueued::class,
+ 'data->format' => 'filament',
+ 'data->title' => 'Operation queued',
+ ]);
+
+ $notification = $user->notifications()->latest('id')->first();
+ expect($notification)->not->toBeNull();
+ expect($notification->data['actions'][0]['url'] ?? null)
+ ->toBe(OperationRunLinks::view($run, $tenant));
+});
+
+it('does not emit queued notifications for runs without an initiator', function () {
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+ $this->actingAs($user);
+
+ $tenant->makeCurrent();
+ Filament::setTenant($tenant, true);
+
+ $service = app(OperationRunService::class);
+
+ $run = $service->ensureRun(
+ tenant: $tenant,
+ type: 'policy.sync',
+ inputs: ['scope' => 'all'],
+ initiator: null,
+ );
+
+ $service->dispatchOrFail($run, function (): void {
+ // no-op
+ });
+
+ expect($user->notifications()->count())->toBe(0);
+});
+
+it('emits a terminal notification when an operation run transitions to completed', function () {
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+ $this->actingAs($user);
+
+ $tenant->makeCurrent();
+ Filament::setTenant($tenant, true);
+
+ $run = OperationRun::factory()->create([
+ 'tenant_id' => $tenant->getKey(),
+ 'user_id' => $user->getKey(),
+ 'initiator_name' => $user->name,
+ 'type' => 'inventory.sync',
+ 'status' => 'queued',
+ 'outcome' => 'pending',
+ 'context' => ['policy_types' => ['deviceConfiguration']],
+ ]);
+
+ $service = app(OperationRunService::class);
+
+ $service->updateRun(
+ $run,
+ status: 'completed',
+ outcome: 'succeeded',
+ summaryCounts: ['observed' => 1],
+ failures: [],
+ );
+
+ $this->assertDatabaseHas('notifications', [
+ 'notifiable_id' => $user->getKey(),
+ 'notifiable_type' => $user->getMorphClass(),
+ 'type' => OperationRunCompleted::class,
+ 'data->format' => 'filament',
+ 'data->title' => 'Operation completed',
+ ]);
+
+ $notification = $user->notifications()->latest('id')->first();
+ expect($notification)->not->toBeNull();
+ expect($notification->data['actions'][0]['url'] ?? null)
+ ->toBe(OperationRunLinks::view($run, $tenant));
+});
+
+it('marks a run failed if dispatch throws synchronously', function () {
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+ $this->actingAs($user);
+
+ $tenant->makeCurrent();
+ Filament::setTenant($tenant, true);
+
+ $service = app(OperationRunService::class);
+
+ $run = $service->ensureRun(
+ tenant: $tenant,
+ type: 'policy.sync',
+ inputs: ['scope' => 'all'],
+ initiator: $user,
+ );
+
+ expect(fn () => $service->dispatchOrFail($run, function (): void {
+ throw new RuntimeException('Queue misconfigured');
+ }))
+ ->toThrow(RuntimeException::class);
+
+ $run->refresh();
+ expect($run->status)->toBe('completed');
+ expect($run->outcome)->toBe('failed');
+});
diff --git a/tests/Feature/OperationRunServiceTest.php b/tests/Feature/OperationRunServiceTest.php
new file mode 100644
index 0000000..998b1eb
--- /dev/null
+++ b/tests/Feature/OperationRunServiceTest.php
@@ -0,0 +1,171 @@
+create();
+ $user = User::factory()->create();
+
+ $service = new OperationRunService;
+
+ $run = $service->ensureRun($tenant, 'test.action', ['scope' => 'full'], $user);
+
+ expect($run)->toBeInstanceOf(OperationRun::class);
+
+ $this->assertDatabaseHas('operation_runs', [
+ 'id' => $run->getKey(),
+ 'tenant_id' => $tenant->getKey(),
+ 'type' => 'test.action',
+ 'status' => 'queued',
+ 'initiator_name' => $user->name,
+ ]);
+});
+
+it('reuses an active run (idempotent)', function () {
+ $tenant = Tenant::factory()->create();
+
+ $service = new OperationRunService;
+
+ $runA = $service->ensureRun($tenant, 'test.action', ['scope' => 'full']);
+ $runB = $service->ensureRun($tenant, 'test.action', ['scope' => 'full']);
+
+ expect($runA->getKey())->toBe($runB->getKey());
+ expect(OperationRun::query()->count())->toBe(1);
+});
+
+it('does not replace the initiator when deduping', function () {
+ $tenant = Tenant::factory()->create();
+ $userA = User::factory()->create();
+ $userB = User::factory()->create();
+
+ $service = new OperationRunService;
+
+ $runA = $service->ensureRun($tenant, 'test.action', ['scope' => 'full'], $userA);
+ $runB = $service->ensureRun($tenant, 'test.action', ['scope' => 'full'], $userB);
+
+ expect($runA->getKey())->toBe($runB->getKey());
+ expect($runB->fresh()?->user_id)->toBe($userA->getKey());
+ expect($runB->fresh()?->initiator_name)->toBe($userA->name);
+});
+
+it('hashes inputs deterministically regardless of key order', function () {
+ $tenant = Tenant::factory()->create();
+ $service = new OperationRunService;
+
+ $runA = $service->ensureRun($tenant, 'test.action', ['b' => 2, 'a' => 1]);
+ $runB = $service->ensureRun($tenant, 'test.action', ['a' => 1, 'b' => 2]);
+
+ expect($runA->getKey())->toBe($runB->getKey());
+});
+
+it('hashes list inputs deterministically regardless of list order', function () {
+ $tenant = Tenant::factory()->create();
+ $service = new OperationRunService;
+
+ $runA = $service->ensureRun($tenant, 'test.action', ['ids' => [2, 1]]);
+ $runB = $service->ensureRun($tenant, 'test.action', ['ids' => [1, 2]]);
+
+ expect($runA->getKey())->toBe($runB->getKey());
+});
+
+it('handles unique-index race collisions by returning the active run', function () {
+ $tenant = Tenant::factory()->create();
+ $service = new OperationRunService;
+
+ $fired = false;
+
+ $dispatcher = OperationRun::getEventDispatcher();
+
+ OperationRun::creating(function (OperationRun $model) use (&$fired): void {
+ if ($fired) {
+ return;
+ }
+
+ $fired = true;
+
+ OperationRun::withoutEvents(function () use ($model): void {
+ OperationRun::query()->create([
+ 'tenant_id' => $model->tenant_id,
+ 'user_id' => $model->user_id,
+ 'initiator_name' => $model->initiator_name,
+ 'type' => $model->type,
+ 'status' => $model->status,
+ 'outcome' => $model->outcome,
+ 'run_identity_hash' => $model->run_identity_hash,
+ 'context' => $model->context,
+ ]);
+ });
+ });
+
+ try {
+ $run = $service->ensureRun($tenant, 'test.race', ['scope' => 'full']);
+ } finally {
+ OperationRun::flushEventListeners();
+ OperationRun::setEventDispatcher($dispatcher);
+ }
+
+ expect($run)->toBeInstanceOf(OperationRun::class);
+ expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->where('type', 'test.race')->count())
+ ->toBe(1);
+});
+
+it('creates a new run after the previous one completed', function () {
+ $tenant = Tenant::factory()->create();
+
+ $service = new OperationRunService;
+
+ $runA = $service->ensureRun($tenant, 'test.action', ['scope' => 'full']);
+ $runA->update(['status' => 'completed']);
+
+ $runB = $service->ensureRun($tenant, 'test.action', ['scope' => 'full']);
+
+ expect($runA->getKey())->not->toBe($runB->getKey());
+ expect(OperationRun::query()->count())->toBe(2);
+});
+
+it('updates run lifecycle fields and summaries', function () {
+ $tenant = Tenant::factory()->create();
+
+ $service = new OperationRunService;
+
+ $run = $service->ensureRun($tenant, 'test.action', []);
+
+ $service->updateRun($run, 'running');
+
+ $fresh = $run->fresh();
+ expect($fresh?->status)->toBe('running');
+ expect($fresh?->started_at)->not->toBeNull();
+
+ $service->updateRun($run, 'completed', 'succeeded', ['success' => 1]);
+
+ $fresh = $run->fresh();
+ expect($fresh?->status)->toBe('completed');
+ expect($fresh?->outcome)->toBe('succeeded');
+ expect($fresh?->completed_at)->not->toBeNull();
+ expect($fresh?->summary_counts)->toBe(['success' => 1]);
+});
+
+it('sanitizes failure messages and redacts obvious secrets', function () {
+ $tenant = Tenant::factory()->create();
+ $service = new OperationRunService;
+
+ $run = $service->ensureRun($tenant, 'test.action', []);
+
+ try {
+ throw new RuntimeException('Authorization: Bearer abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789');
+ } catch (Throwable $e) {
+ $service->failRun($run, $e);
+ }
+
+ $fresh = $run->fresh();
+ expect($fresh?->status)->toBe('completed');
+ expect($fresh?->outcome)->toBe('failed');
+ expect($fresh?->failure_summary)->toBeArray();
+
+ $message = (string) (($fresh?->failure_summary[0]['message'] ?? ''));
+ expect($message)->not->toContain('abcdefghijklmnopqrstuvwxyz');
+ expect($message)->toContain('[REDACTED]');
+});
diff --git a/tests/Feature/PolicySyncStartSurfaceTest.php b/tests/Feature/PolicySyncStartSurfaceTest.php
new file mode 100644
index 0000000..c457e3e
--- /dev/null
+++ b/tests/Feature/PolicySyncStartSurfaceTest.php
@@ -0,0 +1,181 @@
+actingAs($user);
+
+ $tenant->makeCurrent();
+ Filament::setTenant($tenant, true);
+
+ Livewire::test(ListPolicies::class)
+ ->mountAction('sync')
+ ->callMountedAction()
+ ->assertHasNoActionErrors();
+
+ $requestedTypes = array_map(
+ static fn (array $typeConfig): string => (string) $typeConfig['type'],
+ config('tenantpilot.supported_policy_types', [])
+ );
+
+ sort($requestedTypes);
+
+ $run = OperationRun::query()
+ ->where('tenant_id', $tenant->getKey())
+ ->where('type', 'policy.sync')
+ ->latest('id')
+ ->first();
+
+ expect($run)->not->toBeNull();
+ expect($run?->status)->toBe('queued');
+ expect($run?->context)->toMatchArray([
+ 'scope' => 'all',
+ 'types' => $requestedTypes,
+ ]);
+
+ Queue::assertPushed(SyncPoliciesJob::class, function (SyncPoliciesJob $job) use ($tenant, $run, $requestedTypes): bool {
+ return $job->tenantId === (int) $tenant->getKey()
+ && $job->types === $requestedTypes
+ && $job->operationRun instanceof OperationRun
+ && (int) $job->operationRun->getKey() === (int) $run?->getKey();
+ });
+});
+
+it('reuses an active policy sync run and does not enqueue twice', function () {
+ Queue::fake();
+
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+ $this->actingAs($user);
+
+ $tenant->makeCurrent();
+ Filament::setTenant($tenant, true);
+
+ Livewire::test(ListPolicies::class)
+ ->mountAction('sync')
+ ->callMountedAction();
+
+ Livewire::test(ListPolicies::class)
+ ->mountAction('sync')
+ ->callMountedAction();
+
+ Queue::assertPushed(SyncPoliciesJob::class, 1);
+
+ expect(OperationRun::query()
+ ->where('tenant_id', $tenant->getKey())
+ ->where('type', 'policy.sync')
+ ->count())->toBe(1);
+});
+
+it('queues row policy sync and creates a canonical operation run (no Graph calls in request)', function () {
+ Queue::fake();
+ bindFailHardGraphClient();
+
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+ $this->actingAs($user);
+
+ $tenant->makeCurrent();
+ Filament::setTenant($tenant, true);
+
+ $policy = Policy::factory()->create([
+ 'tenant_id' => $tenant->getKey(),
+ 'ignored_at' => null,
+ ]);
+
+ Livewire::test(ListPolicies::class)
+ ->callTableAction('sync', $policy)
+ ->assertHasNoTableActionErrors();
+
+ $run = OperationRun::query()
+ ->where('tenant_id', $tenant->getKey())
+ ->where('type', 'policy.sync_one')
+ ->latest('id')
+ ->first();
+
+ expect($run)->not->toBeNull();
+ expect($run?->status)->toBe('queued');
+ expect($run?->context)->toMatchArray([
+ 'scope' => 'one',
+ 'policy_id' => (int) $policy->getKey(),
+ ]);
+
+ Queue::assertPushed(SyncPoliciesJob::class, function (SyncPoliciesJob $job) use ($tenant, $run, $policy): bool {
+ return $job->tenantId === (int) $tenant->getKey()
+ && $job->types === null
+ && $job->policyIds === [(int) $policy->getKey()]
+ && $job->operationRun instanceof OperationRun
+ && (int) $job->operationRun->getKey() === (int) $run?->getKey();
+ });
+});
+
+it('queues bulk policy sync and creates a canonical operation run (no Graph calls in request)', function () {
+ Queue::fake();
+ bindFailHardGraphClient();
+
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+ $this->actingAs($user);
+
+ $tenant->makeCurrent();
+ Filament::setTenant($tenant, true);
+
+ $policies = Policy::factory()->count(2)->create([
+ 'tenant_id' => $tenant->getKey(),
+ 'ignored_at' => null,
+ ]);
+
+ Livewire::test(ListPolicies::class)
+ ->callTableBulkAction('bulk_sync', $policies)
+ ->assertHasNoTableBulkActionErrors();
+
+ $selectedIds = $policies
+ ->pluck('id')
+ ->map(static fn ($id): int => (int) $id)
+ ->sort()
+ ->values()
+ ->all();
+
+ $run = OperationRun::query()
+ ->where('tenant_id', $tenant->getKey())
+ ->where('type', 'policy.sync')
+ ->latest('id')
+ ->first();
+
+ expect($run)->not->toBeNull();
+ expect($run?->status)->toBe('queued');
+ expect($run?->context)->toMatchArray([
+ 'scope' => 'subset',
+ 'policy_ids' => $selectedIds,
+ ]);
+
+ Queue::assertPushed(SyncPoliciesJob::class, function (SyncPoliciesJob $job) use ($tenant, $run, $selectedIds): bool {
+ return $job->tenantId === (int) $tenant->getKey()
+ && $job->types === null
+ && $job->policyIds === $selectedIds
+ && $job->operationRun instanceof OperationRun
+ && (int) $job->operationRun->getKey() === (int) $run?->getKey();
+ });
+});
+
+it('hides policy sync start action for readonly users', function () {
+ Queue::fake();
+
+ [$user, $tenant] = createUserWithTenant(role: 'readonly');
+ $this->actingAs($user);
+
+ $tenant->makeCurrent();
+ Filament::setTenant($tenant, true);
+
+ Livewire::test(ListPolicies::class)
+ ->assertActionHidden('sync');
+
+ Queue::assertNothingPushed();
+});
diff --git a/tests/Feature/RestoreAdapterTest.php b/tests/Feature/RestoreAdapterTest.php
new file mode 100644
index 0000000..5fa645f
--- /dev/null
+++ b/tests/Feature/RestoreAdapterTest.php
@@ -0,0 +1,93 @@
+create([
+ 'status' => RestoreRunStatus::Checked->value,
+ ]);
+
+ expect(OperationRun::query()
+ ->where('tenant_id', $restoreRun->tenant_id)
+ ->where('type', 'restore.execute')
+ ->count())->toBe(0);
+
+ $restoreRun->update(['status' => RestoreRunStatus::Previewed->value]);
+
+ $opRun = OperationRun::query()
+ ->where('tenant_id', $restoreRun->tenant_id)
+ ->where('type', 'restore.execute')
+ ->latest('id')
+ ->first();
+
+ expect($opRun)->not->toBeNull();
+ expect($opRun?->status)->toBe('queued');
+ expect($opRun?->outcome)->toBe('pending');
+ expect($opRun?->context)->toMatchArray([
+ 'restore_run_id' => (int) $restoreRun->getKey(),
+ 'backup_set_id' => (int) $restoreRun->backup_set_id,
+ 'is_dry_run' => (bool) $restoreRun->is_dry_run,
+ ]);
+});
+
+it('updates the operation run when restore completes', function () {
+ $restoreRun = RestoreRun::factory()->create([
+ 'status' => RestoreRunStatus::Previewed->value,
+ ]);
+
+ $opRun = OperationRun::query()
+ ->where('tenant_id', $restoreRun->tenant_id)
+ ->where('type', 'restore.execute')
+ ->latest('id')
+ ->first();
+
+ expect($opRun)->not->toBeNull();
+ expect($opRun?->status)->toBe('queued');
+
+ $restoreRun->update([
+ 'status' => RestoreRunStatus::Completed->value,
+ 'results' => [
+ 'assignment_outcomes' => [
+ ['status' => 'success'],
+ ['status' => 'failed'],
+ ['status' => 'skipped'],
+ ],
+ ],
+ ]);
+
+ $opRun->refresh();
+
+ expect($opRun->status)->toBe('completed');
+ expect($opRun->outcome)->toBe('succeeded');
+ expect($opRun->summary_counts)->toMatchArray([
+ 'assignments_success' => 1,
+ 'assignments_failed' => 1,
+ 'assignments_skipped' => 1,
+ ]);
+ expect($opRun->completed_at)->not->toBeNull();
+});
+
+it('maps cancelled restore runs to failed outcome (cancelled is reserved)', function () {
+ $restoreRun = RestoreRun::factory()->create([
+ 'status' => RestoreRunStatus::Previewed->value,
+ ]);
+
+ $opRun = OperationRun::query()
+ ->where('tenant_id', $restoreRun->tenant_id)
+ ->where('type', 'restore.execute')
+ ->latest('id')
+ ->first();
+
+ expect($opRun)->not->toBeNull();
+
+ $restoreRun->update(['status' => RestoreRunStatus::Cancelled->value]);
+
+ $opRun->refresh();
+
+ expect($opRun->status)->toBe('completed');
+ expect($opRun->outcome)->toBe('failed');
+ expect($opRun->failure_summary)->toBeArray();
+ expect($opRun->failure_summary[0]['code'] ?? null)->toBe('restore.cancelled');
+});
diff --git a/tests/Feature/Scheduling/PruneOldOperationRunsScheduleTest.php b/tests/Feature/Scheduling/PruneOldOperationRunsScheduleTest.php
new file mode 100644
index 0000000..370df03
--- /dev/null
+++ b/tests/Feature/Scheduling/PruneOldOperationRunsScheduleTest.php
@@ -0,0 +1,15 @@
+events())
+ ->first(fn ($event) => ($event->description ?? null) === PruneOldOperationRunsJob::class);
+
+ expect($event)->not->toBeNull();
+ expect($event->withoutOverlapping)->toBeTrue();
+});
diff --git a/tests/Feature/TrackOperationRunMiddlewareTest.php b/tests/Feature/TrackOperationRunMiddlewareTest.php
new file mode 100644
index 0000000..20e4c6e
--- /dev/null
+++ b/tests/Feature/TrackOperationRunMiddlewareTest.php
@@ -0,0 +1,43 @@
+ensureRun(
+ tenant: $tenant,
+ type: 'test.release',
+ inputs: ['foo' => 'bar'],
+ initiator: $user,
+ );
+
+ $job = new class($operationRun) implements ShouldQueue
+ {
+ use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+ public function __construct(public OperationRun $operationRun)
+ {
+ $this->withFakeQueueInteractions();
+ }
+ };
+
+ $middleware = new TrackOperationRun;
+
+ $middleware->handle($job, function ($job): void {
+ $job->release(60);
+ });
+
+ $operationRun->refresh();
+ expect($operationRun->status)->toBe('running');
+ expect($operationRun->outcome)->toBe('pending');
+});